├── .claude ├── claude.md └── settings.json ├── .cursor ├── mcp.json └── rules │ ├── context7.mdc │ └── typescript.mdc ├── .cursorrules ├── .env.example ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── release-please-config.json ├── release-please-manifest.json └── workflows │ ├── check-semantic-pull-request.yml │ ├── ci.yml │ ├── publish.yml │ └── release-please.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .nvmrc ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── biome.json ├── docs ├── dev-setup.md ├── mcp-server.md └── tool-design.md ├── openai-mcp-tools.md ├── package-lock.json ├── package.json ├── renovate.json ├── scripts ├── test-executable.cjs └── validate-schemas.ts ├── src ├── filter-helpers.ts ├── index.ts ├── main.ts ├── mcp-helpers.ts ├── mcp-server.ts ├── todoist-tool.ts ├── tool-helpers.test.ts ├── tool-helpers.ts ├── tools │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ ├── add-comments.test.ts.snap │ │ │ ├── add-projects.test.ts.snap │ │ │ ├── add-sections.test.ts.snap │ │ │ ├── add-tasks.test.ts.snap │ │ │ ├── complete-tasks.test.ts.snap │ │ │ ├── delete-object.test.ts.snap │ │ │ ├── find-activity.test.ts.snap │ │ │ ├── find-comments.test.ts.snap │ │ │ ├── find-completed-tasks.test.ts.snap │ │ │ ├── find-projects.test.ts.snap │ │ │ ├── find-sections.test.ts.snap │ │ │ ├── find-tasks-by-date.test.ts.snap │ │ │ ├── find-tasks.test.ts.snap │ │ │ ├── get-overview.test.ts.snap │ │ │ ├── update-comments.test.ts.snap │ │ │ ├── update-projects.test.ts.snap │ │ │ └── update-sections.test.ts.snap │ │ ├── add-comments.test.ts │ │ ├── add-projects.test.ts │ │ ├── add-sections.test.ts │ │ ├── add-tasks.test.ts │ │ ├── assignment-integration.test.ts │ │ ├── complete-tasks.test.ts │ │ ├── delete-object.test.ts │ │ ├── fetch.test.ts │ │ ├── find-activity.test.ts │ │ ├── find-comments.test.ts │ │ ├── find-completed-tasks.test.ts │ │ ├── find-projects.test.ts │ │ ├── find-sections.test.ts │ │ ├── find-tasks-by-date.test.ts │ │ ├── find-tasks.test.ts │ │ ├── get-overview.test.ts │ │ ├── search.test.ts │ │ ├── update-comments.test.ts │ │ ├── update-projects.test.ts │ │ ├── update-sections.test.ts │ │ ├── update-tasks.test.ts │ │ └── user-info.test.ts │ ├── add-comments.ts │ ├── add-projects.ts │ ├── add-sections.ts │ ├── add-tasks.ts │ ├── complete-tasks.ts │ ├── delete-object.ts │ ├── fetch.ts │ ├── find-activity.ts │ ├── find-comments.ts │ ├── find-completed-tasks.ts │ ├── find-project-collaborators.ts │ ├── find-projects.ts │ ├── find-sections.ts │ ├── find-tasks-by-date.ts │ ├── find-tasks.ts │ ├── get-overview.ts │ ├── manage-assignments.ts │ ├── search.ts │ ├── update-comments.ts │ ├── update-projects.ts │ ├── update-sections.ts │ ├── update-tasks.ts │ └── user-info.ts └── utils │ ├── assignment-validator.ts │ ├── constants.ts │ ├── duration-parser.test.ts │ ├── duration-parser.ts │ ├── labels.ts │ ├── output-schemas.ts │ ├── priorities.ts │ ├── response-builders.ts │ ├── sanitize-data.test.ts │ ├── sanitize-data.ts │ ├── test-helpers.ts │ ├── tool-names.ts │ └── user-resolver.ts ├── tsconfig.json └── vite.config.ts /.claude/claude.md: -------------------------------------------------------------------------------- 1 | # Todoist AI MCP Server - Development Guidelines 2 | 3 | ## Tool Schema Design Rules 4 | 5 | ### Removing/Clearing Optional Fields 6 | 7 | When you need to support clearing an optional field: 8 | 9 | 1. **Use a special string value** (not `null` - avoids LLM provider compatibility issues, with Gemini in particular) 10 | - For assignments: use `"unassign"` 11 | - For other fields: use `"remove"` or similar descriptive string 12 | 13 | 2. **Handle both legacy and new patterns in runtime logic** for backward compatibility: 14 | ```typescript 15 | if (fieldValue === null || fieldValue === 'remove') { 16 | // Convert to null for API call 17 | updateArgs = { ...updateArgs, fieldName: null } 18 | } 19 | ``` 20 | 21 | 3. **Update schema description** to document the special string value 22 | 23 | ### Examples from Codebase 24 | 25 | - **PR #181**: Fixed `responsibleUser` field - changed from `.nullable()` to using `"unassign"` string 26 | - **Latest commit**: Fixed `deadlineDate` field - changed from `.nullable()` to using `"remove"` string 27 | 28 | ### Why This Matters 29 | 30 | - Ensures compatibility with **all LLM providers** (OpenAI, Anthropic, Gemini, etc.) 31 | - Maintains backward compatibility through dual handling 32 | - Creates self-documenting APIs with explicit action strings 33 | 34 | ## Testing Requirements 35 | 36 | When adding new tool parameters: 37 | 38 | 1. Add comprehensive test coverage for new fields 39 | 2. Test setting values 40 | 3. Test clearing values (if applicable) 41 | 4. Verify build and type checking pass 42 | 5. Run full test suite (all 333+ tests must pass) 43 | 44 | ## Documentation Requirements 45 | 46 | When adding new tool features: 47 | 48 | 1. Update tool schema descriptions in the source file 49 | 2. Update `src/mcp-server.ts` tool usage guidelines 50 | 3. Add tests demonstrating the feature 51 | 4. Include examples in descriptions where helpful 52 | -------------------------------------------------------------------------------- /.claude/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(grep:*)", 5 | "Bash(gh pr view:*)", 6 | "Bash(npm test:*)", 7 | "Bash(npm run type-check:*)", 8 | "Bash(npx tsc:*)" 9 | ], 10 | "deny": [] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "context7": { 4 | "command": "npx", 5 | "args": ["-y", "@upstash/context7-mcp"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.cursor/rules/context7.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Use context7 when referencing library documentation to ensure up-to-date and accurate information 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Context7 Documentation Tool 7 | 8 | **Always use context7 for up-to-date library documentation.** 9 | 10 | Use for: 11 | - Library API references 12 | - Framework documentation 13 | - Package usage examples 14 | - Version-specific features 15 | - Migration guides 16 | 17 | ## Best Practices 18 | 19 | - Always resolve library ID first 20 | - Be specific about topics when possible 21 | - Prefer context7 over web search for established libraries 22 | -------------------------------------------------------------------------------- /.cursor/rules/typescript.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | You are an expert TypeScript programmer with a preference for clean programming and design patterns. Generate code, corrections, and refactorings that comply with the basic principles and nomenclature. 7 | 8 | ## TypeScript General Guidelines 9 | 10 | ### Basic Principles 11 | 12 | - Use English for all code and documentation 13 | - Never use wildcard imports with the `import * as` syntax and always import specific elements directly 14 | - Use Biome for linting and formatting 15 | 16 | ### Typing 17 | 18 | - Always declare the type of each variable and function (parameters and return value) 19 | - Create necessary types 20 | - Avoid using `any` 21 | - Prefer types over interfaces 22 | - Avoid TypeScript enums, instead use string literal types with const objects: 23 | ```typescript 24 | // Instead of: 25 | export enum Status { 26 | ACTIVE = 'active', 27 | INACTIVE = 'inactive' 28 | } 29 | 30 | // Use: 31 | export type Status = 'active' | 'inactive' 32 | ``` 33 | 34 | ### Nomenclature 35 | 36 | - Use PascalCase for classes 37 | - Use camelCase for variables, functions, and methods 38 | - Use kebab-case for file and directory names 39 | - Use UPPERCASE for environment variables 40 | - Avoid magic numbers and define constants 41 | - Start each function with a verb 42 | - Use verbs for boolean variables, e.g.: `isLoading`, `hasError`, `canDelete`, etc 43 | - Use complete words instead of abbreviations and correct spelling 44 | - Except for standard abbreviations like API, URL, etc. 45 | - Except for well-known abbreviations: 46 | - i, j for loops 47 | - err for errors 48 | - ctx for contexts 49 | - req, res, next for middleware function parameters 50 | 51 | ### Functions 52 | 53 | - In this context, what is understood as a function will also apply to a method 54 | - Write short functions with a single purpose 55 | - Name functions with a verb and something else 56 | - If it returns a boolean, use isX or hasX, canX, etc 57 | - If it doesn't return anything, use executeX or saveX, etc 58 | - Avoid nesting blocks by: 59 | - Early checks and returns. 60 | - Extracting to utility functions 61 | - Use higher-order functions (map, filter, reduce, etc) to avoid function nesting 62 | - Use arrow functions only for unnamed functions (e.g., `map((n) => n + 1)`). 63 | - Use the `function fnName()` notation for all named function declarations, regardless of function length 64 | - Use default parameter values instead of checking for `null` or `undefined` 65 | - If a function has more than 1 parameter, make it a named argument function 66 | - Reduce function parameters using RO-RO 67 | - Use an object to pass multiple parameters 68 | - Use an object to return results 69 | - Declare necessary types for input arguments and output 70 | - Use a single level of abstraction 71 | 72 | ### Data 73 | 74 | - Don't abuse primitive types and encapsulate data in composite types 75 | - Avoid data validations in functions and use classes with internal validation 76 | - Prefer immutability for data 77 | - Use readonly for data that doesn't change 78 | - Use as const for literals that don't change 79 | 80 | ### Classes 81 | 82 | - Avoid classes, prefer functions and composition 83 | 84 | ### Error handling 85 | 86 | - Use exceptions to handle errors you don't expect 87 | - If you catch an exception, it should be to: 88 | - Fix an expected problem 89 | - Add context 90 | - Otherwise, use a global handler 91 | 92 | ### Testing 93 | 94 | - Follow the Arrange-Act-Assert convention for tests 95 | - Name test variables clearly 96 | - Follow the convention: inputX, mockX, actualX, expectedX, etc 97 | - Write unit tests for each public function 98 | - Use test doubles to simulate dependencies 99 | - Except for third-party dependencies that are not expensive to execute 100 | - Write acceptance tests for each module 101 | - Follow the Given-When-Then convention 102 | -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- 1 | # Todoist AI MCP Server - Cursor Rules 2 | 3 | ## Critical: Tool Schema Compatibility 4 | 5 | ### NEVER use .nullable() in tool schemas 6 | - Google Gemini API fails with nullable types in OpenAPI 3.1 format 7 | - Use `.optional()` only for optional string fields 8 | - See PR #181 for reference 9 | 10 | ### Pattern for clearing optional fields: 11 | ```typescript 12 | // ❌ WRONG - breaks Gemini 13 | fieldName: z.string().nullable().optional() 14 | 15 | // ✅ CORRECT 16 | fieldName: z.string().optional() 17 | 18 | // Runtime handling for clearing: 19 | if (fieldValue === null || fieldValue === 'remove') { 20 | updateArgs = { ...updateArgs, fieldName: null } 21 | } 22 | ``` 23 | 24 | ### Use special strings for removal: 25 | - Use "unassign" for assignments 26 | - Use "remove" for other clearable fields 27 | - Document in schema descriptions 28 | 29 | ## Testing Requirements 30 | - Add tests for new parameters 31 | - All 333+ tests must pass 32 | - Verify build and type-check 33 | 34 | ## Documentation 35 | - Update schema descriptions 36 | - Update src/mcp-server.ts guidelines 37 | - Add usage examples 38 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TODOIST_API_KEY=your-key-goes-here 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | Closes #... 4 | 5 | ## Short description 6 | 7 | 10 | 11 | ## PR Checklist 12 | 13 | Feel free to leave unchecked or remove the lines that are not applicable. 14 | 15 | - [ ] Added tests for bugs / new features 16 | - [ ] Updated docs (README, etc.) 17 | - [ ] New tools added to `getMcpServer` AND exported in `src/index.ts`. 18 | 19 | -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "changelog-path": "CHANGELOG.md", 5 | "release-type": "node", 6 | "bump-minor-pre-major": false, 7 | "bump-patch-for-minor-pre-major": false, 8 | "draft": false, 9 | "prerelease": false, 10 | "include-component-in-tag": false 11 | } 12 | }, 13 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 14 | } 15 | -------------------------------------------------------------------------------- /.github/release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "4.5.1" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/check-semantic-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Semantic Pull Request 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - edited 7 | - opened 8 | - synchronize 9 | push: 10 | branches: 11 | - gh-readonly-queue/main/** 12 | 13 | jobs: 14 | validate-title: 15 | name: Validate Title 16 | runs-on: ubuntu-latest 17 | 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | timeout-minutes: 5 21 | steps: 22 | - name: Validate pull request title 23 | uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 24 | with: 25 | validateSingleCommit: true -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | # Only a single workflow will run at a time. Cancel any in-progress run. 10 | concurrency: 11 | group: ci-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test: 16 | timeout-minutes: 60 17 | runs-on: ubicloud # `ubuntu-latest` is the official drop-in replacement 18 | 19 | services: 20 | memcached: 21 | image: memcached:1.6-alpine 22 | ports: 23 | - 11211:11211 24 | options: >- 25 | --health-cmd "echo 'stats' | nc localhost 11211" 26 | 27 | env: 28 | MEMCACHED_HOST: localhost 29 | MEMCACHED_PORT: 11211 30 | 31 | NODE_ENV: test 32 | CI: true 33 | 34 | steps: 35 | - uses: actions/checkout@v5 36 | with: 37 | lfs: true 38 | 39 | - name: Setup Node.js 40 | uses: actions/setup-node@v6 41 | with: 42 | node-version-file: ".nvmrc" 43 | cache: "npm" 44 | 45 | - name: Install dependencies 46 | run: npm ci 47 | 48 | - name: Run unit tests 49 | run: npm run test 50 | 51 | - name: Run type checks 52 | run: npm run type-check 53 | 54 | - name: Run linting checks 55 | run: npm run lint:check && npm run format:check 56 | 57 | - name: Validate tool schemas 58 | run: npm run lint:schemas 59 | 60 | - name: Test MCP server executable 61 | run: npm run test:executable 62 | env: 63 | TODOIST_API_KEY: fake-api-key-for-testing 64 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release @doist/todoist-ai package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | # Enable the use of OIDC for npm provenance 8 | contents: read 9 | id-token: write 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | # Based on historical data 15 | timeout-minutes: 60 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v5 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v6 22 | with: 23 | node-version-file: ".nvmrc" 24 | registry-url: "https://registry.npmjs.org" 25 | 26 | - name: Ensure npm 11.5.1 or later is installed 27 | run: npm install -g npm@latest 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | 32 | - name: Build package 33 | run: npm run build 34 | 35 | - name: Type check 36 | run: npm run type-check 37 | 38 | - name: Lint check 39 | run: npm run lint:check 40 | 41 | - name: Format check 42 | run: npm run format:check 43 | 44 | - name: Publish to npm 45 | run: npm publish --provenance --access public 46 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | issues: write 12 | actions: write 13 | 14 | jobs: 15 | release-please: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: googleapis/release-please-action@v4 19 | id: release 20 | with: 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | release-type: node 23 | config-file: .github/release-please-config.json 24 | manifest-file: .github/release-please-manifest.json 25 | 26 | - name: Trigger publish workflow 27 | if: ${{ steps.release.outputs.release_created }} 28 | run: gh workflow run publish.yml --repo ${{ github.repository }} 29 | env: 30 | GH_TOKEN: ${{ github.token }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | .DS_Store 5 | *.log* 6 | .npm 7 | *.tgz 8 | .claude/settings.local.json 9 | .tmp/ 10 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | npm run type-check 3 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm test 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "biome.enabled": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.biome": "explicit", 5 | "source.organizeImports.biome": "explicit" 6 | }, 7 | "[css]": { 8 | "editor.defaultFormatter": "biomejs.biome" 9 | }, 10 | "[javascript]": { 11 | "editor.defaultFormatter": "biomejs.biome" 12 | }, 13 | "[typescript]": { 14 | "editor.defaultFormatter": "biomejs.biome" 15 | }, 16 | "[typescriptreact]": { 17 | "editor.defaultFormatter": "biomejs.biome" 18 | }, 19 | "[json]": { 20 | "editor.defaultFormatter": "biomejs.biome" 21 | }, 22 | "[jsonc]": { 23 | "editor.defaultFormatter": "biomejs.biome" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ernesto García 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 | # Todoist AI and MCP SDK 2 | 3 | Library for connecting AI agents to Todoist. Includes tools that can be integrated into LLMs, 4 | enabling them to access and modify a Todoist account on the user's behalf. 5 | 6 | These tools can be used both through an MCP server, or imported directly in other projects to 7 | integrate them to your own AI conversational interfaces. 8 | 9 | ## Using tools 10 | 11 | ### 1. Add this repository as a dependency 12 | 13 | ```sh 14 | npm install @doist/todoist-ai 15 | ``` 16 | 17 | ### 2. Import the tools and plug them to an AI 18 | 19 | Here's an example using [Vercel's AI SDK](https://ai-sdk.dev/docs/ai-sdk-core/generating-text#streamtext). 20 | 21 | ```js 22 | import { findTasksByDate, addTasks } from "@doist/todoist-ai"; 23 | import { TodoistApi } from "@doist/todoist-api-typescript"; 24 | import { streamText } from "ai"; 25 | 26 | // Create Todoist API client 27 | const client = new TodoistApi(process.env.TODOIST_API_KEY); 28 | 29 | // Helper to wrap tools with the client 30 | function wrapTool(tool, todoistClient) { 31 | return { 32 | ...tool, 33 | execute(args) { 34 | return tool.execute(args, todoistClient); 35 | }, 36 | }; 37 | } 38 | 39 | const result = streamText({ 40 | model: yourModel, 41 | system: "You are a helpful Todoist assistant", 42 | tools: { 43 | findTasksByDate: wrapTool(findTasksByDate, client), 44 | addTasks: wrapTool(addTasks, client), 45 | }, 46 | }); 47 | ``` 48 | 49 | ## Using as an MCP server 50 | 51 | ### Quick Start 52 | 53 | You can run the MCP server directly with npx: 54 | 55 | ```bash 56 | npx @doist/todoist-ai 57 | ``` 58 | 59 | ### Setup Guide 60 | 61 | The Todoist AI MCP server is available as a streamable HTTP service for easy integration with various AI clients: 62 | 63 | **Primary URL (Streamable HTTP):** `https://ai.todoist.net/mcp` 64 | 65 | #### Claude Desktop 66 | 67 | 1. Open Settings → Connectors → Add custom connector 68 | 2. Enter `https://ai.todoist.net/mcp` and complete OAuth authentication 69 | 70 | #### Cursor 71 | 72 | Create a configuration file: 73 | - **Global:** `~/.cursor/mcp.json` 74 | - **Project-specific:** `.cursor/mcp.json` 75 | 76 | ```json 77 | { 78 | "mcpServers": { 79 | "todoist": { 80 | "command": "npx", 81 | "args": ["-y", "mcp-remote", "https://ai.todoist.net/mcp"] 82 | } 83 | } 84 | } 85 | ``` 86 | 87 | Then enable the server in Cursor settings if prompted. 88 | 89 | #### Claude Code (CLI) 90 | 91 | Firstly configure Claude so it has a new MCP available using this command: 92 | 93 | ```bash 94 | claude mcp add --transport http todoist https://ai.todoist.net/mcp 95 | ``` 96 | 97 | Then launch `claude`, execute `/mcp`, then select the `todoist` MCP server. 98 | 99 | This will take you through a wizard to authenticate using your browser with Todoist. Once complete you will be able to use todoist in `claude`. 100 | 101 | 102 | #### Visual Studio Code 103 | 104 | 1. Open Command Palette → MCP: Add Server 105 | 2. Select HTTP transport and use: 106 | 107 | ```json 108 | { 109 | "servers": { 110 | "todoist": { 111 | "type": "http", 112 | "url": "https://ai.todoist.net/mcp" 113 | } 114 | } 115 | } 116 | ``` 117 | 118 | #### Other MCP Clients 119 | 120 | ```bash 121 | npx -y mcp-remote https://ai.todoist.net/mcp 122 | ``` 123 | 124 | For more details on setting up and using the MCP server, including creating custom servers, see [docs/mcp-server.md](docs/mcp-server.md). 125 | 126 | ## Features 127 | 128 | A key feature of this project is that tools can be reused, and are not written specifically for use in an MCP server. They can be hooked up as tools to other conversational AI interfaces (e.g. Vercel's AI SDK). 129 | 130 | This project is in its early stages. Expect more and/or better tools soon. 131 | 132 | Nevertheless, our goal is to provide a small set of tools that enable complete workflows, rather than just atomic actions, striking a balance between flexibility and efficiency for LLMs. 133 | 134 | For our design philosophy, guidelines, and development patterns, see [docs/tool-design.md](docs/tool-design.md). 135 | 136 | ### Available Tools 137 | 138 | For a complete list of available tools, see the [src/tools](src/tools) directory. 139 | 140 | #### OpenAI MCP Compatibility 141 | 142 | This server includes `search` and `fetch` tools that follow the [OpenAI MCP specification](https://platform.openai.com/docs/mcp), enabling seamless integration with OpenAI's MCP protocol. These tools return JSON-encoded results optimized for OpenAI's requirements while maintaining compatibility with the broader MCP ecosystem. 143 | 144 | ## Dependencies 145 | 146 | - MCP server using the official [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#installation) 147 | - Todoist Typescript API client [@doist/todoist-api-typescript](https://github.com/Doist/todoist-api-typescript) 148 | 149 | ## MCP Server Setup 150 | 151 | See [docs/mcp-server.md](docs/mcp-server.md) for full instructions on setting up the MCP server. 152 | 153 | ## Local Development Setup 154 | 155 | See [docs/dev-setup.md](docs/dev-setup.md) for full instructions on setting up this repository locally for development and contributing. 156 | 157 | ### Quick Start 158 | 159 | After cloning and setting up the repository: 160 | 161 | - `npm start` - Build and run the MCP inspector for testing 162 | - `npm run dev` - Development mode with auto-rebuild and restart 163 | 164 | ## Releasing 165 | 166 | This project uses [release-please](https://github.com/googleapis/release-please) to automate version management and package publishing. 167 | 168 | ### How it works 169 | 170 | 1. Make your changes using [Conventional Commits](https://www.conventionalcommits.org/): 171 | 172 | - `feat:` for new features (minor version bump) 173 | - `fix:` for bug fixes (patch version bump) 174 | - `feat!:` or `fix!:` for breaking changes (major version bump) 175 | - `docs:` for documentation changes 176 | - `chore:` for maintenance tasks 177 | - `ci:` for CI changes 178 | 179 | 2. When commits are pushed to `main`: 180 | 181 | - Release-please automatically creates/updates a release PR 182 | - The PR includes version bump and changelog updates 183 | - Review the PR and merge when ready 184 | 185 | 3. After merging the release PR: 186 | - A new GitHub release is automatically created 187 | - A new tag is created 188 | - The `publish` workflow is triggered 189 | - The package is published to npm 190 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "formatWithErrors": false, 6 | "indentStyle": "space", 7 | "indentWidth": 4, 8 | "lineEnding": "lf", 9 | "lineWidth": 100, 10 | "attributePosition": "auto" 11 | }, 12 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 13 | "linter": { 14 | "enabled": true, 15 | "rules": { 16 | "recommended": true, 17 | "style": { 18 | "useImportType": "off", 19 | "noEnum": "error" 20 | }, 21 | "correctness": { 22 | "noUnusedVariables": "error", 23 | "noUnusedImports": "error", 24 | "noUnusedPrivateClassMembers": "error", 25 | "noUnusedFunctionParameters": "error" 26 | }, 27 | "nursery": {}, 28 | "performance": { 29 | "noNamespaceImport": "error" 30 | } 31 | } 32 | }, 33 | "javascript": { 34 | "formatter": { 35 | "jsxQuoteStyle": "double", 36 | "quoteProperties": "asNeeded", 37 | "trailingCommas": "all", 38 | "semicolons": "asNeeded", 39 | "arrowParentheses": "always", 40 | "bracketSpacing": true, 41 | "bracketSameLine": false, 42 | "quoteStyle": "single", 43 | "attributePosition": "auto" 44 | }, 45 | "parser": { 46 | "unsafeParameterDecoratorsEnabled": true 47 | } 48 | }, 49 | "vcs": { 50 | "enabled": true, 51 | "clientKind": "git", 52 | "useIgnoreFile": true 53 | }, 54 | "files": { 55 | "includes": ["**", "!**/test/fixtures/**/*", "!**/package.json"] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/dev-setup.md: -------------------------------------------------------------------------------- 1 | # Development Setup 2 | 3 | ## 1. Install dependencies and set up environment 4 | 5 | ```sh 6 | npm run setup 7 | ``` 8 | 9 | ## 2. Configure environment variables 10 | 11 | Update the `.env` file with your Todoist token: 12 | 13 | ```env 14 | TODOIST_API_KEY=your-key-goes-here 15 | TODOIST_BASE_URL=https://local.todoist.com/api/v1 16 | ``` 17 | 18 | The `TODOIST_BASE_URL` is optional and defaults to the official Todoist API endpoint. You may need to change this for development or testing purposes. 19 | 20 | ## 3. Run the MCP server with inspector 21 | 22 | ### For development (with auto-rebuild): 23 | 24 | ```sh 25 | npm run dev 26 | ``` 27 | 28 | This command starts the TypeScript compiler in watch mode and automatically restarts the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) whenever you make changes to the source code. 29 | 30 | In MCP Inspector, ensure that the Transport type is `STDIO`, the command is `npx` and the arguments `nodemon --quiet --watch dist --ext js --exec node dist/main.js`, this will make sure that the MCP inspector is pointing at your locally running setup. 31 | 32 | ### For testing the built version: 33 | 34 | ```sh 35 | npm start 36 | ``` 37 | 38 | This command builds the project and runs the MCP inspector once with the compiled code. Use this to test the final built version without auto-reload functionality. 39 | -------------------------------------------------------------------------------- /docs/mcp-server.md: -------------------------------------------------------------------------------- 1 | # MCP Server Setup 2 | 3 | This document outlines the steps necessary to run this MCP server and connect to an MCP host application, such as Claude Desktop or Cursor. 4 | 5 | ## Quick Setup 6 | 7 | The easiest way to use this MCP server is with npx: 8 | 9 | ```bash 10 | npx @doist/todoist-ai 11 | ``` 12 | 13 | You'll need to set your Todoist API key as an environment variable `TODOIST_API_KEY`. 14 | 15 | ## Local Development Setup 16 | 17 | Start by cloning this repository and setting it up locally, if you haven't done so yet. 18 | 19 | ```sh 20 | git clone https://github.com/Doist/todoist-ai 21 | npm run setup 22 | ``` 23 | 24 | To test the server locally before connecting it to an MCP client, you can use: 25 | 26 | ```sh 27 | npm start 28 | ``` 29 | 30 | This will build the project and run the MCP inspector for manual testing. 31 | 32 | ### Creating a Custom MCP Server 33 | 34 | For convenience, we also include a function that initializes an MCP Server with all the tools available: 35 | 36 | ```js 37 | import { getMcpServer } from "@doist/todoist-ai"; 38 | 39 | async function main() { 40 | const server = getMcpServer({ todoistApiKey: process.env.TODOIST_API_KEY }); 41 | const transport = new StdioServerTransport(); 42 | await server.connect(transport); 43 | } 44 | ``` 45 | 46 | Then, proceed depending on the MCP protocol transport you'll use. 47 | 48 | ## Using Standard I/O Transport 49 | 50 | ### Quick Setup with npx 51 | 52 | Add this section to your `mcp.json` config in Claude, Cursor, etc.: 53 | 54 | ```json 55 | { 56 | "mcpServers": { 57 | "todoist-ai": { 58 | "type": "stdio", 59 | "command": "npx", 60 | "args": ["@doist/todoist-ai"], 61 | "env": { 62 | "TODOIST_API_KEY": "your-todoist-token-here" 63 | } 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | ### Using local installation 70 | 71 | Add this `todoist-ai-tools` section to your `mcp.json` config in Cursor, Claude, Raycast, etc. 72 | 73 | ```json 74 | { 75 | "mcpServers": { 76 | "todoist-ai-tools": { 77 | "type": "stdio", 78 | "command": "node", 79 | "args": ["/Users//code/todoist-ai-tools/dist/main.js"], 80 | "env": { 81 | "TODOIST_API_KEY": "your-todoist-token-here" 82 | } 83 | } 84 | } 85 | } 86 | ``` 87 | 88 | Update the configuration above as follows 89 | 90 | - Replace `TODOIST_API_KEY` with your Todoist API token. 91 | - Replace the path in the `args` array with the correct path to where you cloned the repository 92 | 93 | > [!NOTE] 94 | > You may also need to change the command, passing the full path to your `node` binary, depending one how you installed `node`. 95 | 96 | ## Using Streamable HTTP Server Transport 97 | 98 | Unfortunately, MCP host applications do not yet support connecting to an MCP server hosted via HTTP. There's a workaround to run them through a bridge that exposes them locally via Standard I/O. 99 | 100 | Start by running the service via a web server. You can do it locally like this: 101 | 102 | ```sh 103 | PORT=8080 npm run dev:http 104 | ``` 105 | 106 | This will expose the service at the URL http://localhost:8080/mcp. You can now configure Claude Desktop: 107 | 108 | ```json 109 | { 110 | "mcpServers": { 111 | "todoist-mcp-http": { 112 | "type": "stdio", 113 | "command": "npx", 114 | "args": ["mcp-remote", "http://localhost:8080/mcp"] 115 | } 116 | } 117 | } 118 | ``` 119 | 120 | > [!NOTE] 121 | > You may also need to change the command, passing the full path to your `npx` binary, depending one how you installed `node`. 122 | -------------------------------------------------------------------------------- /docs/tool-design.md: -------------------------------------------------------------------------------- 1 | # Tool Design Guidelines 2 | 3 | ## Philosophy: Specialized Workflow Tools 4 | 5 | We use **specialized workflow tools** instead of 30+ API-endpoint tools. 6 | 7 | **Traditional** (API-centric): `add-project`, `update-project`, `delete-project`, `get-project`... 8 | **Our approach** (workflow-centric): `add-projects`, `update-projects`, `delete-object` (universal deletion) 9 | 10 | ## Core Principles 11 | 12 | 1. **User Intent Over API Structure** - Match workflows, not API organization 13 | 2. **Batch Operations** - Support multiple items when logical (`add-tasks` takes array) 14 | 3. **Explicit Intent** - Clear action verbs (`add-projects` vs `update-projects` for explicit operations) 15 | 4. **Context-Aware Responses** - Provide next-step suggestions after operations 16 | 5. **Universal Patterns** - Unify similar operations (`delete-object` handles all entity types) 17 | 18 | ## Implementation Pattern 19 | 20 | ```typescript 21 | // Explicit tools for specific operations 22 | // add-projects: creates new projects 23 | // update-projects: modifies existing projects 24 | 25 | // Always return both structured data AND human-readable text with next steps 26 | return getToolOutput({ 27 | textContent: `Action completed: ${result}\n${formatNextSteps(suggestions)}`, 28 | structuredContent: { data, metadata }, 29 | }); 30 | ``` 31 | 32 | ## Key Design Decisions 33 | 34 | | Choice | Rationale | 35 | | --------------------------------------------------- | ------------------------------------------------------------------ | 36 | | `add-projects` + `update-projects` vs combined tool | Explicit intent reduces ambiguity, clearer tool selection for LLMs | 37 | | `delete-object` (universal) vs type-specific tools | Deletion logic similar across types, reduces LLM cognitive load | 38 | | `add-tasks` (batch) vs single task | Common to add multiple related tasks at once | 39 | | Rich responses with next steps | Maintains workflow momentum, helps LLMs choose follow-up actions | 40 | 41 | ## Guidelines 42 | 43 | ### When to Create New Tool 44 | 45 | - Represents distinct user workflow 46 | - Can't elegantly extend existing tools 47 | - Has unique parameter requirements 48 | 49 | ### When to Extend Existing Tool 50 | 51 | - Closely related to existing tool's purpose 52 | - Can be handled with additional parameters 53 | - Follows same workflow pattern 54 | 55 | ### Naming & Design 56 | 57 | - **Action verbs**: `find-`, `add-`, `manage-`, `complete-` 58 | - **User terminology**: `complete-tasks` not `close-tasks` 59 | - **Batch support**: When users commonly do multiple items 60 | - **Smart defaults**: Optional parameters, auto-detect intent 61 | - **Rich responses**: Structured data + human text + next steps 62 | 63 | ## OpenAI MCP Tools 64 | 65 | **Exception to the Design Philosophy**: The `search` and `fetch` tools follow the [OpenAI MCP specification](https://platform.openai.com/docs/mcp) which requires specific return formats: 66 | 67 | - **`search`**: Returns JSON-encoded array of results with `id`, `title`, `url` 68 | - **`fetch`**: Returns JSON-encoded object with `id`, `title`, `text`, `url`, `metadata` 69 | 70 | These tools return raw JSON strings instead of rich responses with next steps, as required by OpenAI's protocol. They use composite IDs (`task:{id}` or `project:{id}`) to distinguish between entity types. 71 | 72 | ## Anti-Patterns ❌ 73 | 74 | - One-to-one API mapping without added value 75 | - Overly complex parameters for basic operations 76 | - Inconsistent interfaces across similar tools 77 | - Raw API responses without context 78 | - Forcing multiple tool calls for related operations 79 | -------------------------------------------------------------------------------- /openai-mcp-tools.md: -------------------------------------------------------------------------------- 1 | # search tool 2 | 3 | The `search` tool is responsible for returning a list of relevant search results from your MCP server's data source, given a user's query. 4 | 5 | *Arguments:* 6 | 7 | A single query string. 8 | 9 | *Returns:* 10 | 11 | An object with a single key, `results`, whose value is an array of result objects. Each result object should include: 12 | 13 | - `id` - a unique ID for the document or search result item 14 | - `title` - human-readable title. 15 | - `url` - canonical URL for citation. 16 | 17 | In MCP, tool results must be returned as a content array containing one or more "content items." Each content item has a type (such as `text`, `image`, or `resource`) and a payload. 18 | 19 | For the `search` tool, you should return **exactly one** content item with: 20 | 21 | - `type: "text"` 22 | - `text`: a JSON-encoded string matching the results array schema above. 23 | 24 | The final tool response should look like: 25 | 26 | ```json 27 | { 28 | "content": [ 29 | { 30 | "type": "text", 31 | "text": "{\"results\":[{\"id\":\"doc-1\",\"title\":\"...\",\"url\":\"...\"}]}" 32 | } 33 | ] 34 | } 35 | ``` 36 | 37 | # fetch tool 38 | 39 | The fetch tool is used to retrieve the full contents of a search result document or item. 40 | 41 | *Arguments:* 42 | 43 | A string which is a unique identifier for the search document. 44 | 45 | *Returns:* 46 | 47 | A single object with the following properties: 48 | 49 | - `id` - a unique ID for the document or search result item 50 | - `title` - a string title for the search result item 51 | - `text` - The full text of the document or item 52 | - `url` - a URL to the document or search result item. Useful for citing specific resources in research. 53 | - `metadata` - an optional key/value pairing of data about the result 54 | 55 | In MCP, tool results must be returned as a content array containing one or more "content items." Each content item has a `type` (such as `text`, `image`, or `resource`) and a payload. 56 | 57 | In this case, the `fetch` tool must return exactly one content item with `type: "text"`. The `text` field should be a JSON-encoded string of the document object following the schema above. 58 | 59 | The final tool response should look like: 60 | 61 | ```json 62 | { 63 | "content": [ 64 | { 65 | "type": "text", 66 | "text": "{\"id\":\"doc-1\",\"title\":\"...\",\"text\":\"full text...\",\"url\":\"...\"}" 67 | } 68 | ] 69 | } 70 | ``` 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@doist/todoist-ai", 3 | "version": "4.17.0", 4 | "type": "module", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "mcpName": "net.todoist/mcp", 8 | "bin": { 9 | "todoist-ai": "dist/main.js" 10 | }, 11 | "files": [ 12 | "dist", 13 | "scripts", 14 | "package.json", 15 | "LICENSE.txt", 16 | "README.md" 17 | ], 18 | "license": "MIT", 19 | "description": "A collection of tools for Todoist using AI", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/Doist/todoist-ai" 23 | }, 24 | "keywords": [ 25 | "todoist", 26 | "ai", 27 | "tools" 28 | ], 29 | "scripts": { 30 | "test": "vitest run", 31 | "test:watch": "vitest", 32 | "test:coverage": "vitest run --coverage", 33 | "build": "vite build", 34 | "postbuild": "chmod +x dist/main.js", 35 | "start": "npm run build && npx @modelcontextprotocol/inspector node dist/main.js", 36 | "dev": "concurrently \"vite build --watch\" \"npx @modelcontextprotocol/inspector npx nodemon --quiet --watch dist --ext js --exec node dist/main.js\"", 37 | "setup": "cp .env.example .env && npm install && npm run build", 38 | "test:executable": "npm run build && node scripts/test-executable.cjs", 39 | "type-check": "npx tsc --noEmit", 40 | "biome:sort-imports": "biome check --formatter-enabled=false --linter-enabled=false --organize-imports-enabled=true --write .", 41 | "lint:check": "biome lint", 42 | "lint:write": "biome lint --write", 43 | "format:check": "biome format", 44 | "format:write": "biome format --write", 45 | "lint:schemas": "npm run build && npx tsc scripts/validate-schemas.ts --outDir dist/scripts --moduleResolution node --module ESNext --target es2021 --esModuleInterop --skipLibCheck --declaration false && node dist/scripts/validate-schemas.js", 46 | "check": "biome check && npm run lint:schemas", 47 | "check:fix": "biome check --fix --unsafe", 48 | "prepare": "husky" 49 | }, 50 | "dependencies": { 51 | "@doist/todoist-api-typescript": "6.0.0", 52 | "@modelcontextprotocol/sdk": "^1.11.1", 53 | "date-fns": "^4.1.0", 54 | "dotenv": "^17.0.0", 55 | "zod": "^3.25.7" 56 | }, 57 | "devDependencies": { 58 | "@biomejs/biome": "2.3.2", 59 | "@types/express": "5.0.5", 60 | "@types/morgan": "1.9.10", 61 | "@types/node": "22.19.0", 62 | "concurrently": "9.2.1", 63 | "express": "5.1.0", 64 | "husky": "9.1.7", 65 | "lint-staged": "16.2.6", 66 | "morgan": "1.10.1", 67 | "nodemon": "3.1.10", 68 | "rimraf": "6.1.0", 69 | "typescript": "5.9.3", 70 | "vite": "7.1.12", 71 | "vite-plugin-dts": "4.5.4", 72 | "vitest": "3.2.4" 73 | }, 74 | "lint-staged": { 75 | "*": [ 76 | "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true" 77 | ], 78 | "src/tools/*.ts": [ 79 | "npm run lint:schemas" 80 | ], 81 | "src/index.ts": [ 82 | "npm run lint:schemas" 83 | ], 84 | "scripts/validate-schemas.ts": [ 85 | "npm run lint:schemas" 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":semanticCommitTypeAll(chore)", 6 | "github>doist/renovate-config:frontend-base" 7 | ], 8 | "schedule": ["before 4am on Monday"], 9 | "timezone": "UTC", 10 | "labels": ["dependencies"], 11 | "assigneesFromCodeOwners": true, 12 | "reviewersFromCodeOwners": true, 13 | "packageRules": [ 14 | { 15 | "matchPackageNames": ["*"], 16 | "groupName": "all dependencies", 17 | "groupSlug": "all" 18 | }, 19 | { 20 | "matchDepTypes": ["devDependencies"], 21 | "groupName": "dev dependencies", 22 | "groupSlug": "dev" 23 | }, 24 | { 25 | "matchDepTypes": ["dependencies"], 26 | "groupName": "production dependencies", 27 | "groupSlug": "prod" 28 | }, 29 | { 30 | "matchPackageNames": ["zod"], 31 | "allowedVersions": "<4.0.0", 32 | "description": "Pin zod to v3 until the @modelcontextprotocol/sdk supports zod v4" 33 | } 34 | ], 35 | "vulnerabilityAlerts": { 36 | "enabled": true 37 | }, 38 | "lockFileMaintenance": { 39 | "enabled": true, 40 | "schedule": ["before 4am on Monday"] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /scripts/test-executable.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { spawn } = require('node:child_process') 4 | const path = require('node:path') 5 | 6 | console.log('Testing MCP server executable...') 7 | 8 | const mainJs = path.join(__dirname, '..', 'dist', 'main.js') 9 | const child = spawn('node', [mainJs], { 10 | stdio: ['pipe', 'pipe', 'pipe'], 11 | }) 12 | 13 | let _stdoutOutput = '' 14 | let stderrOutput = '' 15 | let hasError = false 16 | 17 | child.stdout.on('data', (data) => { 18 | _stdoutOutput += data.toString() 19 | }) 20 | 21 | child.stderr.on('data', (data) => { 22 | const output = data.toString() 23 | stderrOutput += output 24 | 25 | // Only consider it an error if it's not related to graceful shutdown 26 | if (output.includes('Error:') && !output.includes('SIGTERM') && !output.includes('SIGKILL')) { 27 | console.error('Server startup error detected:', output) 28 | hasError = true 29 | } 30 | }) 31 | 32 | child.on('error', (error) => { 33 | console.error('Failed to start MCP server:', error.message) 34 | hasError = true 35 | process.exit(1) 36 | }) 37 | 38 | child.on('exit', (code, signal) => { 39 | // Expected signals when we kill the process 40 | if (signal === 'SIGTERM' || signal === 'SIGKILL') { 41 | return // This is expected 42 | } 43 | 44 | // Unexpected exit codes during startup 45 | if (code !== null && code !== 0) { 46 | console.error(`Server exited unexpectedly with code ${code}`) 47 | hasError = true 48 | } 49 | }) 50 | 51 | // Kill the process after 2 seconds (MCP server should start successfully) 52 | setTimeout(() => { 53 | if (hasError) { 54 | console.error('❌ MCP server failed to start properly') 55 | if (stderrOutput.trim()) { 56 | console.error('Error output:', stderrOutput.trim()) 57 | } 58 | process.exit(1) 59 | } 60 | 61 | // Gracefully terminate 62 | child.kill('SIGTERM') 63 | 64 | setTimeout(() => { 65 | console.log('✅ MCP server executable test passed') 66 | console.log('Server started successfully and is ready to accept connections') 67 | process.exit(0) 68 | }, 200) // Give it a moment to clean up 69 | }, 2000) 70 | -------------------------------------------------------------------------------- /src/filter-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { TodoistApi } from '@doist/todoist-api-typescript' 2 | import { resolveUserNameToId } from './utils/user-resolver.js' 3 | 4 | export const RESPONSIBLE_USER_FILTERING = ['assigned', 'unassignedOrMe', 'all'] as const 5 | export type ResponsibleUserFiltering = (typeof RESPONSIBLE_USER_FILTERING)[number] 6 | 7 | /** 8 | * Resolves a responsible user name/email to user ID and email. 9 | * @param client - Todoist API client 10 | * @param responsibleUser - User identifier (can be user ID, name, or email) 11 | * @returns Object with userId and email, or undefined if not provided 12 | * @throws Error if user cannot be found 13 | */ 14 | export async function resolveResponsibleUser( 15 | client: TodoistApi, 16 | responsibleUser: string | undefined, 17 | ): Promise<{ userId: string; email: string } | undefined> { 18 | if (!responsibleUser) { 19 | return undefined 20 | } 21 | 22 | const resolved = await resolveUserNameToId(client, responsibleUser) 23 | if (!resolved) { 24 | throw new Error( 25 | `Could not find user: "${responsibleUser}". Make sure the user is a collaborator on a shared project.`, 26 | ) 27 | } 28 | 29 | return { userId: resolved.userId, email: resolved.email } 30 | } 31 | 32 | /** 33 | * Appends a filter component to a query string with proper ' & ' separator. 34 | * @param query - The existing query string 35 | * @param filterComponent - The filter component to append 36 | * @returns The updated query string 37 | */ 38 | export function appendToQuery(query: string, filterComponent: string): string { 39 | if (filterComponent.length === 0) { 40 | return query 41 | } 42 | if (query.length === 0) { 43 | return filterComponent 44 | } 45 | return `${query} & ${filterComponent}` 46 | } 47 | 48 | /** 49 | * Builds a query filter string for responsible user filtering that can be appended to a Todoist filter query. 50 | * @param resolvedAssigneeId - The resolved assignee ID (if provided) 51 | * @param assigneeEmail - The assignee email (if provided) 52 | * @param responsibleUserFiltering - The filtering mode ('assigned', 'unassignedOrMe', 'all') 53 | * @returns Query filter string (e.g., "assigned to: email@example.com" or "!assigned to: others") 54 | */ 55 | export function buildResponsibleUserQueryFilter({ 56 | resolvedAssigneeId, 57 | assigneeEmail, 58 | responsibleUserFiltering = 'unassignedOrMe', 59 | }: { 60 | resolvedAssigneeId: string | undefined 61 | assigneeEmail: string | undefined 62 | responsibleUserFiltering?: ResponsibleUserFiltering 63 | }): string { 64 | if (resolvedAssigneeId && assigneeEmail) { 65 | // If specific user is provided, filter by that user 66 | return `assigned to: ${assigneeEmail}` 67 | } 68 | 69 | // Otherwise use the filtering mode 70 | if (responsibleUserFiltering === 'unassignedOrMe') { 71 | // Exclude tasks assigned to others (keeps unassigned + assigned to me) 72 | return '!assigned to: others' 73 | } 74 | 75 | if (responsibleUserFiltering === 'assigned') { 76 | // Only tasks assigned to others 77 | return 'assigned to: others' 78 | } 79 | 80 | // For 'all', don't add any assignment filter 81 | return '' 82 | } 83 | 84 | /** 85 | * Filters tasks based on responsible user logic: 86 | * - If resolvedAssigneeId is provided: returns only tasks assigned to that user 87 | * - If no resolvedAssigneeId: returns only unassigned tasks or tasks assigned to current user 88 | * @param tasks - Array of tasks to filter (must have responsibleUid property) 89 | * @param resolvedAssigneeId - The resolved assignee ID to filter by (optional) 90 | * @param currentUserId - The current authenticated user's ID 91 | * @returns Filtered array of tasks 92 | */ 93 | export function filterTasksByResponsibleUser({ 94 | tasks, 95 | resolvedAssigneeId, 96 | currentUserId, 97 | responsibleUserFiltering = 'unassignedOrMe', 98 | }: { 99 | tasks: T[] 100 | resolvedAssigneeId: string | undefined 101 | currentUserId: string 102 | responsibleUserFiltering?: ResponsibleUserFiltering 103 | }): T[] { 104 | if (resolvedAssigneeId) { 105 | // If responsibleUser provided, only return tasks assigned to that user 106 | return tasks.filter((task) => task.responsibleUid === resolvedAssigneeId) 107 | } else { 108 | // If no responsibleUser, only return unassigned tasks or tasks assigned to current user 109 | return responsibleUserFiltering === 'unassignedOrMe' 110 | ? tasks.filter((task) => !task.responsibleUid || task.responsibleUid === currentUserId) 111 | : tasks 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getMcpServer } from './mcp-server.js' 2 | // Comment management tools 3 | import { addComments } from './tools/add-comments.js' 4 | // Project management tools 5 | import { addProjects } from './tools/add-projects.js' 6 | // Section management tools 7 | import { addSections } from './tools/add-sections.js' 8 | // Task management tools 9 | import { addTasks } from './tools/add-tasks.js' 10 | import { completeTasks } from './tools/complete-tasks.js' 11 | // General tools 12 | import { deleteObject } from './tools/delete-object.js' 13 | import { fetch } from './tools/fetch.js' 14 | // Activity and audit tools 15 | import { findActivity } from './tools/find-activity.js' 16 | import { findComments } from './tools/find-comments.js' 17 | import { findCompletedTasks } from './tools/find-completed-tasks.js' 18 | // Assignment and collaboration tools 19 | import { findProjectCollaborators } from './tools/find-project-collaborators.js' 20 | import { findProjects } from './tools/find-projects.js' 21 | import { findSections } from './tools/find-sections.js' 22 | import { findTasks } from './tools/find-tasks.js' 23 | import { findTasksByDate } from './tools/find-tasks-by-date.js' 24 | import { getOverview } from './tools/get-overview.js' 25 | import { manageAssignments } from './tools/manage-assignments.js' 26 | import { search } from './tools/search.js' 27 | import { updateComments } from './tools/update-comments.js' 28 | import { updateProjects } from './tools/update-projects.js' 29 | import { updateSections } from './tools/update-sections.js' 30 | import { updateTasks } from './tools/update-tasks.js' 31 | import { userInfo } from './tools/user-info.js' 32 | 33 | const tools = { 34 | // Task management tools 35 | addTasks, 36 | completeTasks, 37 | updateTasks, 38 | findTasks, 39 | findTasksByDate, 40 | findCompletedTasks, 41 | // Project management tools 42 | addProjects, 43 | updateProjects, 44 | findProjects, 45 | // Section management tools 46 | addSections, 47 | updateSections, 48 | findSections, 49 | // Comment management tools 50 | addComments, 51 | updateComments, 52 | findComments, 53 | // Activity and audit tools 54 | findActivity, 55 | // General tools 56 | getOverview, 57 | deleteObject, 58 | userInfo, 59 | // Assignment and collaboration tools 60 | findProjectCollaborators, 61 | manageAssignments, 62 | // OpenAI MCP tools 63 | search, 64 | fetch, 65 | } 66 | 67 | export { tools, getMcpServer } 68 | 69 | export { 70 | // Task management tools 71 | addTasks, 72 | completeTasks, 73 | updateTasks, 74 | findTasks, 75 | findTasksByDate, 76 | findCompletedTasks, 77 | // Project management tools 78 | addProjects, 79 | updateProjects, 80 | findProjects, 81 | // Section management tools 82 | addSections, 83 | updateSections, 84 | findSections, 85 | // Comment management tools 86 | addComments, 87 | updateComments, 88 | findComments, 89 | // Activity and audit tools 90 | findActivity, 91 | // General tools 92 | getOverview, 93 | deleteObject, 94 | userInfo, 95 | // Assignment and collaboration tools 96 | findProjectCollaborators, 97 | manageAssignments, 98 | // OpenAI MCP tools 99 | search, 100 | fetch, 101 | } 102 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' 3 | import dotenv from 'dotenv' 4 | import { getMcpServer } from './mcp-server.js' 5 | 6 | function main() { 7 | const baseUrl = process.env.TODOIST_BASE_URL 8 | const todoistApiKey = process.env.TODOIST_API_KEY 9 | if (!todoistApiKey) { 10 | throw new Error('TODOIST_API_KEY is not set') 11 | } 12 | 13 | const server = getMcpServer({ todoistApiKey, baseUrl }) 14 | const transport = new StdioServerTransport() 15 | server 16 | .connect(transport) 17 | .then(() => { 18 | // We use console.error because standard I/O is being used for the MCP server communication. 19 | console.error('Server started') 20 | }) 21 | .catch((error) => { 22 | console.error('Error starting the Todoist MCP server:', error) 23 | process.exit(1) 24 | }) 25 | } 26 | 27 | dotenv.config() 28 | main() 29 | -------------------------------------------------------------------------------- /src/mcp-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { TodoistApi } from '@doist/todoist-api-typescript' 2 | import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js' 3 | import type { ZodTypeAny, z } from 'zod' 4 | import type { TodoistTool } from './todoist-tool.js' 5 | import { removeNullFields } from './utils/sanitize-data.js' 6 | 7 | /** 8 | * Wether to return the structured content directly, vs. in the `content` part of the output. 9 | * 10 | * The `structuredContent` part of the output is relatively new in the spec, and it's not yet 11 | * supported by all clients. This flag controls wether we return the structured content using this 12 | * new feature of the MCP protocol or not. 13 | * 14 | * If `false`, the `structuredContent` will be returned as stringified JSON in one of the `content` 15 | * parts. 16 | * 17 | * Eventually we should be able to remove this, and change the code to always work with the 18 | * structured content returned directly, once most or all MCP clients support it. 19 | */ 20 | const USE_STRUCTURED_CONTENT = 21 | process.env.USE_STRUCTURED_CONTENT === 'true' || process.env.NODE_ENV === 'test' 22 | 23 | /** 24 | * Get the output payload for a tool, in the correct format expected by MCP client apps. 25 | * 26 | * @param textContent - The text content to return. 27 | * @param structuredContent - The structured content to return. 28 | * @returns The output payload. 29 | * @see USE_STRUCTURED_CONTENT - Wether to use the structured content feature of the MCP protocol. 30 | */ 31 | function getToolOutput>({ 32 | textContent, 33 | structuredContent, 34 | }: { 35 | textContent: string 36 | structuredContent: StructuredContent 37 | }) { 38 | // Remove null fields from structured content before returning 39 | const sanitizedContent = removeNullFields(structuredContent) 40 | 41 | if (USE_STRUCTURED_CONTENT) { 42 | return { 43 | content: [{ type: 'text' as const, text: textContent }], 44 | structuredContent: sanitizedContent, 45 | } 46 | } 47 | 48 | const json = JSON.stringify(sanitizedContent) 49 | return { 50 | content: [ 51 | { type: 'text' as const, text: textContent }, 52 | { type: 'text' as const, mimeType: 'application/json', text: json }, 53 | ], 54 | } 55 | } 56 | 57 | function getErrorOutput(error: string) { 58 | return { 59 | content: [{ type: 'text' as const, text: error }], 60 | isError: true, 61 | } 62 | } 63 | 64 | /** 65 | * Register a Todoist tool in an MCP server. 66 | * @param tool - The tool to register. 67 | * @param server - The server to register the tool on. 68 | * @param client - The Todoist API client to use to execute the tool. 69 | */ 70 | function registerTool( 71 | tool: TodoistTool, 72 | server: McpServer, 73 | client: TodoistApi, 74 | ) { 75 | // @ts-expect-error I give up 76 | const cb: ToolCallback = async ( 77 | args: z.objectOutputType, 78 | _context, 79 | ) => { 80 | try { 81 | const result = await tool.execute(args as z.infer>, client) 82 | return result 83 | } catch (error) { 84 | console.error(`Error executing tool ${tool.name}:`, { 85 | args, 86 | error, 87 | }) 88 | const message = error instanceof Error ? error.message : 'An unknown error occurred' 89 | return getErrorOutput(message) 90 | } 91 | } 92 | 93 | // Use registerTool to support outputSchema 94 | server.registerTool( 95 | tool.name, 96 | { 97 | description: tool.description, 98 | inputSchema: tool.parameters, 99 | outputSchema: tool.outputSchema as Output, 100 | }, 101 | cb, 102 | ) 103 | } 104 | 105 | export { registerTool, getErrorOutput, getToolOutput } 106 | -------------------------------------------------------------------------------- /src/todoist-tool.ts: -------------------------------------------------------------------------------- 1 | import type { TodoistApi } from '@doist/todoist-api-typescript' 2 | import type { z } from 'zod' 3 | 4 | /** 5 | * A Todoist tool that can be used in an MCP server or other conversational AI interfaces. 6 | */ 7 | type TodoistTool = { 8 | /** 9 | * The name of the tool. 10 | */ 11 | name: string 12 | 13 | /** 14 | * The description of the tool. This is important for the LLM to understand what the tool does, 15 | * and how to use it. 16 | */ 17 | description: string 18 | 19 | /** 20 | * The schema of the parameters of the tool. 21 | * 22 | * This is used to validate the parameters of the tool, as well as to let the LLM know what the 23 | * parameters are. 24 | */ 25 | parameters: Params 26 | 27 | /** 28 | * The schema of the output of the tool. 29 | * 30 | * This is used to describe the structured output format that the tool will return. 31 | */ 32 | outputSchema: Output 33 | 34 | /** 35 | * The function that executes the tool. 36 | * 37 | * This is the main function that will be called when the tool is used. 38 | * 39 | * @param args - The arguments of the tool. 40 | * @param client - The Todoist API client used to make requests to the Todoist API. 41 | * @returns The result of the tool. 42 | */ 43 | execute: (args: z.infer>, client: TodoistApi) => Promise 44 | } 45 | 46 | export type { TodoistTool } 47 | -------------------------------------------------------------------------------- /src/tool-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActivityEvent, 3 | MoveTaskArgs, 4 | PersonalProject, 5 | Task, 6 | TodoistApi, 7 | WorkspaceProject, 8 | } from '@doist/todoist-api-typescript' 9 | import z from 'zod' 10 | import { formatDuration } from './utils/duration-parser.js' 11 | 12 | // Re-export filter helpers for backward compatibility 13 | export { 14 | appendToQuery, 15 | buildResponsibleUserQueryFilter, 16 | filterTasksByResponsibleUser, 17 | RESPONSIBLE_USER_FILTERING, 18 | type ResponsibleUserFiltering, 19 | resolveResponsibleUser, 20 | } from './filter-helpers.js' 21 | 22 | export type Project = PersonalProject | WorkspaceProject 23 | 24 | export function isPersonalProject(project: Project): project is PersonalProject { 25 | return 'inboxProject' in project 26 | } 27 | 28 | export function isWorkspaceProject(project: Project): project is WorkspaceProject { 29 | return 'accessLevel' in project 30 | } 31 | 32 | /** 33 | * Creates a MoveTaskArgs object from move parameters, validating that exactly one is provided. 34 | * @param taskId - The task ID (used for error messages) 35 | * @param projectId - Optional project ID to move to 36 | * @param sectionId - Optional section ID to move to 37 | * @param parentId - Optional parent ID to move to 38 | * @returns MoveTaskArgs object with exactly one destination 39 | * @throws Error if multiple move parameters are provided or none are provided 40 | */ 41 | export function createMoveTaskArgs( 42 | taskId: string, 43 | projectId?: string, 44 | sectionId?: string, 45 | parentId?: string, 46 | ): MoveTaskArgs { 47 | // Validate that only one move parameter is provided (RequireExactlyOne constraint) 48 | const moveParams = [projectId, sectionId, parentId].filter(Boolean) 49 | if (moveParams.length > 1) { 50 | throw new Error( 51 | `Task ${taskId}: Only one of projectId, sectionId, or parentId can be specified at a time. The Todoist API requires exactly one destination for move operations.`, 52 | ) 53 | } 54 | 55 | if (moveParams.length === 0) { 56 | throw new Error( 57 | `Task ${taskId}: At least one of projectId, sectionId, or parentId must be provided for move operations.`, 58 | ) 59 | } 60 | 61 | // Build moveArgs with the single defined value 62 | if (projectId) return { projectId } 63 | if (sectionId) return { sectionId } 64 | if (parentId) return { parentId } 65 | 66 | // This should never be reached due to the validation above 67 | throw new Error('Unexpected error: No valid move parameter found') 68 | } 69 | 70 | /** 71 | * Map a single Todoist task to a more structured format, for LLM consumption. 72 | * @param task - The task to map. 73 | * @returns The mapped task. 74 | */ 75 | function mapTask(task: Task) { 76 | return { 77 | id: task.id, 78 | content: task.content, 79 | description: task.description, 80 | dueDate: task.due?.date, 81 | recurring: task.due?.isRecurring && task.due.string ? task.due.string : false, 82 | deadlineDate: task.deadline?.date, 83 | priority: task.priority, 84 | projectId: task.projectId, 85 | sectionId: task.sectionId, 86 | parentId: task.parentId, 87 | labels: task.labels, 88 | duration: task.duration ? formatDuration(task.duration.amount) : null, 89 | responsibleUid: task.responsibleUid, 90 | assignedByUid: task.assignedByUid, 91 | checked: task.checked, 92 | completedAt: task.completedAt, 93 | } 94 | } 95 | 96 | /** 97 | * Map a single Todoist project to a more structured format, for LLM consumption. 98 | * @param project - The project to map. 99 | * @returns The mapped project. 100 | */ 101 | function mapProject(project: Project) { 102 | return { 103 | id: project.id, 104 | name: project.name, 105 | color: project.color, 106 | isFavorite: project.isFavorite, 107 | isShared: project.isShared, 108 | parentId: isPersonalProject(project) ? (project.parentId ?? null) : null, 109 | inboxProject: isPersonalProject(project) ? (project.inboxProject ?? false) : false, 110 | viewStyle: project.viewStyle, 111 | } 112 | } 113 | 114 | /** 115 | * Map a single Todoist activity event to a more structured format, for LLM consumption. 116 | * @param event - The activity event to map. 117 | * @returns The mapped activity event. 118 | */ 119 | function mapActivityEvent(event: ActivityEvent) { 120 | return { 121 | id: event.id, 122 | objectType: event.objectType, 123 | objectId: event.objectId, 124 | eventType: event.eventType, 125 | eventDate: event.eventDate, 126 | parentProjectId: event.parentProjectId, 127 | parentItemId: event.parentItemId, 128 | initiatorId: event.initiatorId, 129 | extraData: event.extraData, 130 | } 131 | } 132 | 133 | const ErrorSchema = z.object({ 134 | httpStatusCode: z.number(), 135 | responseData: z.object({ 136 | error: z.string(), 137 | errorCode: z.number(), 138 | errorTag: z.string(), 139 | }), 140 | }) 141 | 142 | async function getTasksByFilter({ 143 | client, 144 | query, 145 | limit, 146 | cursor, 147 | }: { 148 | client: TodoistApi 149 | query: string 150 | limit: number | undefined 151 | cursor: string | undefined 152 | }) { 153 | try { 154 | const { results, nextCursor } = await client.getTasksByFilter({ query, cursor, limit }) 155 | const tasks = results.map(mapTask) 156 | return { tasks, nextCursor } 157 | } catch (error) { 158 | const parsedError = ErrorSchema.safeParse(error) 159 | if (!parsedError.success) { 160 | throw error 161 | } 162 | const { responseData } = parsedError.data 163 | if (responseData.errorTag === 'INVALID_SEARCH_QUERY') { 164 | throw new Error(`Invalid filter query: ${query}`) 165 | } 166 | throw new Error( 167 | `${responseData.error} (tag: ${responseData.errorTag}, code: ${responseData.errorCode})`, 168 | ) 169 | } 170 | } 171 | 172 | export { getTasksByFilter, mapActivityEvent, mapProject, mapTask } 173 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/add-comments.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`add-comments tool > adding comments to projects > should add comment to project 1`] = `"Added 1 project comment"`; 4 | 5 | exports[`add-comments tool > adding comments to tasks > should add comment to task 1`] = `"Added 1 task comment"`; 6 | 7 | exports[`add-comments tool > bulk operations > should add multiple comments to different entities (task + project) 1`] = `"Added 1 task comment and 1 project comment"`; 8 | 9 | exports[`add-comments tool > bulk operations > should add multiple comments to different tasks 1`] = `"Added 2 task comments"`; 10 | 11 | exports[`add-comments tool > bulk operations > should add multiple comments to the same task 1`] = `"Added 2 task comments"`; 12 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/add-projects.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`add-projects tool > creating a single project > should create a project and return mapped result 1`] = ` 4 | "Added 1 project: 5 | • test-abc123def456-project (id=6cfCcrrCFg2xP94Q)" 6 | `; 7 | 8 | exports[`add-projects tool > creating a single project > should create project with isFavorite and viewStyle options 1`] = ` 9 | "Added 1 project: 10 | • Board Project (id=project-789)" 11 | `; 12 | 13 | exports[`add-projects tool > creating a single project > should create project with parentId to create a sub-project 1`] = ` 14 | "Added 1 project: 15 | • Child Project (id=project-child)" 16 | `; 17 | 18 | exports[`add-projects tool > creating a single project > should handle different project properties from API 1`] = ` 19 | "Added 1 project: 20 | • My Blue Project (id=project-456)" 21 | `; 22 | 23 | exports[`add-projects tool > creating multiple projects > should create multiple projects and return mapped results 1`] = ` 24 | "Added 3 projects: 25 | • First Project (id=project-1) 26 | • Second Project (id=project-2) 27 | • Third Project (id=project-3)" 28 | `; 29 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/add-sections.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`add-sections tool > creating a single section > should create a section and return mapped result 1`] = ` 4 | "Added 1 section: 5 | • test-abc123def456-section (id=section-123, projectId=6cfCcrrCFg2xP94Q)" 6 | `; 7 | 8 | exports[`add-sections tool > creating a single section > should handle different section properties from API 1`] = ` 9 | "Added 1 section: 10 | • My Section Name (id=section-456, projectId=project-789)" 11 | `; 12 | 13 | exports[`add-sections tool > creating multiple sections > should create multiple sections and return mapped results 1`] = ` 14 | "Added 3 sections: 15 | • First Section (id=section-1, projectId=6cfCcrrCFg2xP94Q) 16 | • Second Section (id=section-2, projectId=6cfCcrrCFg2xP94Q) 17 | • Third Section (id=section-3, projectId=different-project)" 18 | `; 19 | 20 | exports[`add-sections tool > creating multiple sections > should handle sections for the same project 1`] = ` 21 | "Added 2 sections: 22 | • To Do (id=section-1, projectId=6cfCcrrCFg2xP94Q) 23 | • In Progress (id=section-2, projectId=6cfCcrrCFg2xP94Q)" 24 | `; 25 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/add-tasks.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`add-tasks tool > adding multiple tasks > should add multiple tasks and return mapped results 1`] = ` 4 | "Added 2 tasks to projects. 5 | Tasks: 6 | First task content • P4 • id=8485093748 7 | Second task content • due 2025-08-15 • P3 • id=8485093749." 8 | `; 9 | 10 | exports[`add-tasks tool > adding multiple tasks > should add task with deadline 1`] = ` 11 | "Added 1 task to projects. 12 | Tasks: 13 | Task with deadline • P4 • id=8485093756." 14 | `; 15 | 16 | exports[`add-tasks tool > adding multiple tasks > should add tasks with duration 1`] = ` 17 | "Added 2 tasks to projects. 18 | Tasks: 19 | Task with 2 hour duration • P4 • id=8485093752 20 | Task with 45 minute duration • P4 • id=8485093753." 21 | `; 22 | 23 | exports[`add-tasks tool > adding multiple tasks > should handle tasks with section and parent IDs 1`] = ` 24 | "Added 1 task to projects. 25 | Tasks: 26 | Subtask content • P2 • id=8485093750." 27 | `; 28 | 29 | exports[`add-tasks tool > next steps logic > should suggest find-tasks-by-date for today when hasToday is true 1`] = ` 30 | "Added 1 task to projects. 31 | Tasks: 32 | Task due today • due 2025-08-17 • P4 • id=8485093755." 33 | `; 34 | 35 | exports[`add-tasks tool > next steps logic > should suggest overview tool when no hasToday context 1`] = ` 36 | "Added 1 task to projects. 37 | Tasks: 38 | Regular task • P4 • id=8485093756." 39 | `; 40 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/complete-tasks.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`complete-tasks tool > completing multiple tasks > should complete all tasks successfully 1`] = ` 4 | "Completed tasks: 3/3 successful. 5 | Completed: 6 | task-1 7 | task-2 8 | task-3." 9 | `; 10 | 11 | exports[`complete-tasks tool > completing multiple tasks > should complete single task 1`] = ` 12 | "Completed tasks: 1/1 successful. 13 | Completed: 14 | 8485093748." 15 | `; 16 | 17 | exports[`complete-tasks tool > completing multiple tasks > should continue processing remaining tasks after failures 1`] = ` 18 | "Completed tasks: 2/5 successful. 19 | Completed: 20 | task-3 21 | task-5. 22 | Failed (3): 23 | task-1 (Error: Task already completed) 24 | task-2 (Error: Task not found) 25 | task-4 (Error: Permission denied)." 26 | `; 27 | 28 | exports[`complete-tasks tool > completing multiple tasks > should handle all tasks failing 1`] = ` 29 | "Completed tasks: 0/2 successful. 30 | Failed (2): 31 | task-1 (Error: API Error: Network timeout) 32 | task-2 (Error: API Error: Network timeout)." 33 | `; 34 | 35 | exports[`complete-tasks tool > completing multiple tasks > should handle different types of API errors 1`] = ` 36 | "Completed tasks: 0/4 successful. 37 | Failed (4): 38 | not-found (Error: Task not found) 39 | already-done (Error: Task already completed) 40 | no-permission (Error: Permission denied), +1 more." 41 | `; 42 | 43 | exports[`complete-tasks tool > completing multiple tasks > should handle partial failures gracefully 1`] = ` 44 | "Completed tasks: 2/3 successful. 45 | Completed: 46 | task-1 47 | task-3. 48 | Failed (1): 49 | task-2 (Error: Task not found)." 50 | `; 51 | 52 | exports[`complete-tasks tool > edge cases > should handle empty task completion (minimum one task required by schema) 1`] = ` 53 | "Completed tasks: 1/1 successful. 54 | Completed: 55 | single-task." 56 | `; 57 | 58 | exports[`complete-tasks tool > edge cases > should handle tasks with special ID formats 1`] = ` 59 | "Completed tasks: 3/3 successful. 60 | Completed: 61 | proj_123_task_456 62 | task-with-dashes 63 | 1234567890." 64 | `; 65 | 66 | exports[`complete-tasks tool > error message truncation > should not show truncation message for exactly 3 errors 1`] = ` 67 | "Completed tasks: 0/3 successful. 68 | Failed (3): 69 | task-1 (Error: Error 1) 70 | task-2 (Error: Error 2) 71 | task-3 (Error: Error 3)." 72 | `; 73 | 74 | exports[`complete-tasks tool > error message truncation > should truncate failure messages after 3 errors 1`] = ` 75 | "Completed tasks: 0/5 successful. 76 | Failed (5): 77 | task-1 (Error: Error 1) 78 | task-2 (Error: Error 2) 79 | task-3 (Error: Error 3), +2 more." 80 | `; 81 | 82 | exports[`complete-tasks tool > mixed success and failure scenarios > should handle realistic mixed scenario 1`] = ` 83 | "Completed tasks: 3/5 successful. 84 | Completed: 85 | 8485093748 86 | 8485093749 87 | 8485093751. 88 | Failed (2): 89 | 8485093750 (Error: Task already completed) 90 | 8485093752 (Error: Task not found)." 91 | `; 92 | 93 | exports[`complete-tasks tool > next steps logic validation > should suggest checking IDs when all tasks fail 1`] = ` 94 | "Completed tasks: 0/2 successful. 95 | Failed (2): 96 | bad-id-1 (Error: Task not found) 97 | bad-id-2 (Error: Task not found)." 98 | `; 99 | 100 | exports[`complete-tasks tool > next steps logic validation > should suggest overdue tasks when all tasks complete successfully 1`] = ` 101 | "Completed tasks: 2/2 successful. 102 | Completed: 103 | task-1 104 | task-2." 105 | `; 106 | 107 | exports[`complete-tasks tool > next steps logic validation > should suggest reviewing failures when mixed results 1`] = ` 108 | "Completed tasks: 1/2 successful. 109 | Completed: 110 | task-1. 111 | Failed (1): 112 | task-2 (Error: Task not found)." 113 | `; 114 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/delete-object.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`delete-object tool > deleting projects > should delete a project by ID 1`] = `"Deleted project: id=6cfCcrrCFg2xP94Q"`; 4 | 5 | exports[`delete-object tool > deleting sections > should delete a section by ID 1`] = `"Deleted section: id=section-123"`; 6 | 7 | exports[`delete-object tool > deleting tasks > should delete a task by ID 1`] = `"Deleted task: id=8485093748"`; 8 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-activity.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`find-activity tool > basic functionality > should handle empty results 1`] = ` 4 | "Activity events: 0 (limit 20). 5 | No results. No activity events match the specified filters; Note: Activity logs only show recent events." 6 | `; 7 | 8 | exports[`find-activity tool > basic functionality > should retrieve activity events with default parameters 1`] = ` 9 | "Activity events: 2 (limit 20). 10 | Preview: 11 | [Oct 23, 10:00] added task • id=task-456 • by=user-001 • project=project-789 12 | [Oct 23, 11:00] completed task • id=task-456 • by=user-001 • project=project-789" 13 | `; 14 | 15 | exports[`find-activity tool > filtering > should support multiple filters simultaneously 1`] = ` 16 | "Activity: completed tasks: 1 (limit 50). 17 | Filter: project: project-work; initiator: user-bob. 18 | Preview: 19 | [Oct 23, 10:30] completed task • id=task-456 • by=user-bob • project=project-work" 20 | `; 21 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-comments.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`find-comments tool > empty results > should handle no comments found 1`] = `"No comments found for task task123"`; 4 | 5 | exports[`find-comments tool > finding comments by project > should find comments for a project 1`] = `"Found 1 comment for project project456"`; 6 | 7 | exports[`find-comments tool > finding comments by task > should find comments for a task 1`] = `"Found 2 comments for task task123"`; 8 | 9 | exports[`find-comments tool > finding comments by task > should handle pagination 1`] = ` 10 | "Found 1 comment for task task123 • More available 11 | Possible suggested next step: 12 | - Pass cursor 'next_page_token' to fetch more results." 13 | `; 14 | 15 | exports[`find-comments tool > finding single comment > should find comment by ID 1`] = `"Found comment • id=comment789"`; 16 | 17 | exports[`find-comments tool > finding single comment > should handle comment with attachment 1`] = `"Found comment • Has attachment: document.pdf • id=comment789"`; 18 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-completed-tasks.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`find-completed-tasks tool > getting completed tasks by completion date (default) > should get completed tasks by completion date 1`] = ` 4 | "Completed tasks (by completed date): 1 (limit 50). 5 | Filter: completed date: 2025-08-10 to 2025-08-15. 6 | Preview: 7 | Completed task 1 • due 2025-08-14 • P3 • id=8485093748" 8 | `; 9 | 10 | exports[`find-completed-tasks tool > getting completed tasks by completion date (default) > should handle explicit completion date query 1`] = ` 11 | "Completed tasks (by completed date): 0 (limit 100), more available. 12 | Filter: completed date: 2025-08-01 to 2025-08-31; project: specific-project-id. 13 | No results. No tasks completed in this date range; Try expanding the date range; Try removing project/section/parent filters. 14 | Possible suggested next step: 15 | - Pass cursor 'next-cursor' to fetch more results." 16 | `; 17 | 18 | exports[`find-completed-tasks tool > getting completed tasks by due date > should get completed tasks by due date 1`] = ` 19 | "Completed tasks (by due date): 1 (limit 50). 20 | Filter: due date: 2025-08-10 to 2025-08-20. 21 | Preview: 22 | Task completed by due date • due 2025-08-15 • P2 • id=8485093750" 23 | `; 24 | 25 | exports[`find-completed-tasks tool > label filtering > should combine other filters with label filters 1`] = ` 26 | "Completed tasks (by due date): 1 (limit 25). 27 | Filter: due date: 2025-08-01 to 2025-08-31; project: test-project-id; section: test-section-id; labels: @important. 28 | Preview: 29 | Important completed task • P4 • id=8485093748" 30 | `; 31 | 32 | exports[`find-completed-tasks tool > label filtering > should filter completed tasks by labels: 'multiple labels with AND operator' 1`] = ` 33 | "Completed tasks (by due date): 1 (limit 50). 34 | Filter: due date: 2025-08-01 to 2025-08-31; labels: @work & @urgent. 35 | Preview: 36 | Completed task with label • P4 • id=8485093748" 37 | `; 38 | 39 | exports[`find-completed-tasks tool > label filtering > should filter completed tasks by labels: 'multiple labels with OR operator' 1`] = ` 40 | "Completed tasks (by completed date): 1 (limit 25). 41 | Filter: completed date: 2025-08-10 to 2025-08-20; labels: @personal | @shopping. 42 | Preview: 43 | Completed task with label • P4 • id=8485093748" 44 | `; 45 | 46 | exports[`find-completed-tasks tool > label filtering > should filter completed tasks by labels: 'single label with OR operator' 1`] = ` 47 | "Completed tasks (by completed date): 1 (limit 50). 48 | Filter: completed date: 2025-08-01 to 2025-08-31; labels: @work. 49 | Preview: 50 | Completed task with label • P4 • id=8485093748" 51 | `; 52 | 53 | exports[`find-completed-tasks tool > timezone handling > should convert user timezone to UTC correctly (Europe/Madrid) 1`] = ` 54 | "Completed tasks (by completed date): 1 (limit 50). 55 | Filter: completed date: 2025-10-11 to 2025-10-11. 56 | Preview: 57 | Task completed in Madrid timezone • P4 • id=8485093750" 58 | `; 59 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-projects.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`find-projects tool > listing all projects > should handle pagination with limit and cursor 1`] = ` 4 | "Projects: 1 (limit 10), more available. 5 | Preview: 6 | First Project • id=project-1 7 | Possible suggested next step: 8 | - Pass cursor 'next-page-cursor' to fetch more results." 9 | `; 10 | 11 | exports[`find-projects tool > listing all projects > should list all projects when no search parameter is provided 1`] = ` 12 | "Projects: 3 (limit 50). 13 | Preview: 14 | Inbox • Inbox • id=inbox-project-id 15 | test-abc123def456-project • id=6cfCcrrCFg2xP94Q 16 | Work Project • ⭐ • Shared • board • id=work-project-id" 17 | `; 18 | 19 | exports[`find-projects tool > searching projects > should filter projects by search term (case insensitive) 1`] = ` 20 | "Projects matching "work": 2 (limit 50). 21 | Filter: search: "work". 22 | Preview: 23 | Work Project • id=work-project-id 24 | Hobby Work • id=hobby-project-id" 25 | `; 26 | 27 | exports[`find-projects tool > searching projects > should handle search with 'case insensitive matching' 1`] = ` 28 | "Projects matching "IMPORTANT": 1 (limit 50). 29 | Filter: search: "IMPORTANT". 30 | Preview: 31 | Important Project • id=6cfCcrrCFg2xP94Q" 32 | `; 33 | 34 | exports[`find-projects tool > searching projects > should handle search with 'no matches' 1`] = ` 35 | "Projects matching "nonexistent": 0 (limit 50). 36 | Filter: search: "nonexistent". 37 | No results. Try broader search terms; Check spelling; Remove search to see all projects." 38 | `; 39 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-sections.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`find-sections tool > listing all sections in a project > should handle project with no sections 1`] = ` 4 | "Sections in project empty-project-id: 0. 5 | No results. Project has no sections yet; Use add-sections to create sections." 6 | `; 7 | 8 | exports[`find-sections tool > listing all sections in a project > should list all sections when no search parameter is provided 1`] = ` 9 | "Sections in project 6cfCcrrCFg2xP94Q: 4. 10 | Preview: 11 | To Do • id=section-123 12 | In Progress • id=section-456 13 | Done • id=section-789 14 | Backlog Items • id=section-999" 15 | `; 16 | 17 | exports[`find-sections tool > searching sections by name > should filter sections by search term (case insensitive) 1`] = ` 18 | "Sections in project 6cfCcrrCFg2xP94Q matching "progress": 2. 19 | Preview: 20 | In Progress • id=section-456 21 | Progress Review • id=section-999" 22 | `; 23 | 24 | exports[`find-sections tool > searching sections by name > should handle case sensitive search correctly 1`] = ` 25 | "Sections in project 6cfCcrrCFg2xP94Q matching "IMPORTANT": 1. 26 | Preview: 27 | Important Tasks • id=section-123" 28 | `; 29 | 30 | exports[`find-sections tool > searching sections by name > should handle exact matches 1`] = ` 31 | "Sections in project 6cfCcrrCFg2xP94Q matching "done": 2. 32 | Preview: 33 | Done • id=section-123 34 | Done Soon • id=section-456" 35 | `; 36 | 37 | exports[`find-sections tool > searching sections by name > should handle partial matches correctly 1`] = ` 38 | "Sections in project 6cfCcrrCFg2xP94Q matching "task": 2. 39 | Preview: 40 | Development Tasks • id=section-123 41 | Testing Tasks • id=section-456" 42 | `; 43 | 44 | exports[`find-sections tool > searching sections by name > should handle search with no matches 1`] = ` 45 | "Sections in project 6cfCcrrCFg2xP94Q matching "nonexistent": 0. 46 | No results. Try broader search terms; Check spelling; Remove search to see all sections." 47 | `; 48 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-tasks-by-date.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`find-tasks-by-date tool > edge cases > should handle 'empty results' 1`] = ` 4 | "Today's tasks + overdue: 0 (limit 50). 5 | Filter: today + overdue tasks + 6 more days. 6 | No results. Great job! No tasks for today or overdue." 7 | `; 8 | 9 | exports[`find-tasks-by-date tool > label filtering > should combine date filters with label filters 1`] = ` 10 | "Tasks for 2025-08-15: 1 (limit 25). 11 | Filter: 2025-08-15; labels: @important. 12 | Preview: 13 | Important task for specific date • due 2025-08-15 • P4 • id=8485093748" 14 | `; 15 | 16 | exports[`find-tasks-by-date tool > listing tasks by date range > only returns tasks for the startDate when daysCount is 1 1`] = ` 17 | "Tasks for 2025-08-20: 1 (limit 50). 18 | Filter: 2025-08-20. 19 | Preview: 20 | Task for specific date • due 2025-08-20 • P4 • id=8485093748" 21 | `; 22 | 23 | exports[`find-tasks-by-date tool > listing tasks by date range > should get tasks for today when startDate is "today" (includes overdue) 1`] = ` 24 | "Today's tasks + overdue: 1 (limit 50). 25 | Filter: today + overdue tasks + 6 more days. 26 | Preview: 27 | Today task • due 2025-08-15 • P4 • id=8485093748" 28 | `; 29 | 30 | exports[`find-tasks-by-date tool > listing tasks by date range > should handle 'multiple days with pagination' 1`] = ` 31 | "Tasks for 2025-08-20: 2 (limit 20), more available. 32 | Filter: 2025-08-20 to 2025-08-23. 33 | Preview: 34 | Multi-day task 1 • due 2025-08-20 • P4 • id=8485093749 35 | Multi-day task 2 • due 2025-08-21 • P4 • id=8485093750 36 | Possible suggested next step: 37 | - Pass cursor 'next-page-cursor' to fetch more results." 38 | `; 39 | 40 | exports[`find-tasks-by-date tool > listing tasks by date range > should handle 'specific date' 1`] = ` 41 | "Tasks for 2025-08-20: 1 (limit 50). 42 | Filter: 2025-08-20 to 2025-08-27. 43 | Preview: 44 | Specific date task • due 2025-08-20 • P4 • id=8485093748" 45 | `; 46 | 47 | exports[`find-tasks-by-date tool > next steps logic > should provide helpful suggestions for empty date range results 1`] = ` 48 | "Tasks for 2025-08-20: 0 (limit 10). 49 | Filter: 2025-08-20. 50 | No results. Expand date range with larger 'daysCount'; Check today's tasks with startDate='today'." 51 | `; 52 | 53 | exports[`find-tasks-by-date tool > next steps logic > should provide helpful suggestions for empty today results 1`] = ` 54 | "Today's tasks + overdue: 0 (limit 10). 55 | Filter: today + overdue tasks. 56 | No results. Great job! No tasks for today or overdue." 57 | `; 58 | 59 | exports[`find-tasks-by-date tool > next steps logic > should suggest appropriate actions when hasOverdue is true 1`] = ` 60 | "Tasks for 2025-08-15: 1 (limit 10). 61 | Filter: 2025-08-15. 62 | Preview: 63 | Overdue task from list • due 2025-08-10 • P4 • id=8485093748" 64 | `; 65 | 66 | exports[`find-tasks-by-date tool > next steps logic > should suggest today-focused actions when startDate is today 1`] = ` 67 | "Today's tasks + overdue: 1 (limit 10). 68 | Filter: today + overdue tasks. 69 | Preview: 70 | Today's task • due 2025-08-15 • P4 • id=8485093748" 71 | `; 72 | 73 | exports[`find-tasks-by-date tool > responsibleUser parameter > should filter tasks by specific user email 1`] = ` 74 | "Today's tasks + overdue assigned to john@example.com: 1 (limit 50). 75 | Filter: today + overdue tasks; assigned to: john@example.com. 76 | Preview: 77 | Task assigned to John • due 2025-08-15 • P4 • id=8485093748" 78 | `; 79 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-tasks.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`find-tasks tool > container filtering > should find tasks in 'parent task' 1`] = ` 4 | "Subtasks: 1 (limit 10). 5 | Filter: subtasks of 8485093748. 6 | Preview: 7 | Subtask • P4 • id=8485093748" 8 | `; 9 | 10 | exports[`find-tasks tool > container filtering > should find tasks in 'project' 1`] = ` 11 | "Tasks in project: 1 (limit 10). 12 | Filter: in project 6cfCcrrCFg2xP94Q. 13 | Preview: 14 | Project task • P4 • id=8485093748" 15 | `; 16 | 17 | exports[`find-tasks tool > container filtering > should find tasks in 'section' 1`] = ` 18 | "Tasks in section: 1 (limit 10). 19 | Filter: in section section-123. 20 | Preview: 21 | Section task • P4 • id=8485093748" 22 | `; 23 | 24 | exports[`find-tasks tool > next steps logic > should provide different next steps for regular tasks 1`] = ` 25 | "Search results for "future tasks": 1 (limit 10). 26 | Filter: matching "future tasks". 27 | Preview: 28 | Regular future task • due 2025-08-25 • P4 • id=8485093748" 29 | `; 30 | 31 | exports[`find-tasks tool > next steps logic > should provide helpful suggestions for empty search results 1`] = ` 32 | "Search results for "nonexistent": 0 (limit 10). 33 | Filter: matching "nonexistent". 34 | No results. Try broader search terms; Verify spelling and try partial words; Check completed tasks with find-completed-tasks." 35 | `; 36 | 37 | exports[`find-tasks tool > next steps logic > should suggest different actions when hasOverdue is true 1`] = ` 38 | "Search results for "overdue tasks": 1 (limit 10). 39 | Filter: matching "overdue tasks". 40 | Preview: 41 | Overdue search result • due 2025-08-10 • P4 • id=8485093748" 42 | `; 43 | 44 | exports[`find-tasks tool > next steps logic > should suggest today tasks when hasToday is true 1`] = ` 45 | "Search results for "today tasks": 1 (limit 10). 46 | Filter: matching "today tasks". 47 | Preview: 48 | Task due today • due 2025-08-17 • P4 • id=8485093748" 49 | `; 50 | 51 | exports[`find-tasks tool > searching tasks > should handle 'custom limit' 1`] = ` 52 | "Search results for "project update": 1 (limit 5). 53 | Filter: matching "project update". 54 | Preview: 55 | Test result • P4 • id=8485093748" 56 | `; 57 | 58 | exports[`find-tasks tool > searching tasks > should handle 'pagination cursor' 1`] = ` 59 | "Search results for "follow up": 1 (limit 20). 60 | Filter: matching "follow up". 61 | Preview: 62 | Test result • P4 • id=8485093748" 63 | `; 64 | 65 | exports[`find-tasks tool > searching tasks > should handle search with 'empty results' 1`] = ` 66 | "Search results for "nonexistent keyword": 0 (limit 10). 67 | Filter: matching "nonexistent keyword". 68 | No results. Try broader search terms; Verify spelling and try partial words; Check completed tasks with find-completed-tasks." 69 | `; 70 | 71 | exports[`find-tasks tool > searching tasks > should handle search with 'special characters' 1`] = ` 72 | "Search results for "@work #urgent "exact phrase"": 0 (limit 10). 73 | Filter: matching "@work #urgent "exact phrase"". 74 | No results. Try broader search terms; Verify spelling and try partial words; Check completed tasks with find-completed-tasks." 75 | `; 76 | 77 | exports[`find-tasks tool > searching tasks > should search tasks and return results 1`] = ` 78 | "Search results for "important meeting": 2 (limit 10), more available. 79 | Filter: matching "important meeting". 80 | Preview: 81 | Task containing search term • P4 • id=8485093748 82 | Another matching task • P3 • id=8485093749 83 | Possible suggested next step: 84 | - Pass cursor 'cursor-for-next-page' to fetch more results." 85 | `; 86 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/get-overview.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`get-overview tool > account overview (no projectId) > should generate account overview with projects and sections 1`] = ` 4 | "# Personal Projects 5 | 6 | - Inbox Project: Inbox (id=inbox-project-id) 7 | - Project: test-abc123def456-project (id=6cfCcrrCFg2xP94Q) 8 | - Section: test-section (id=section-123) 9 | " 10 | `; 11 | 12 | exports[`get-overview tool > account overview (no projectId) > should handle empty projects list 1`] = ` 13 | "# Personal Projects 14 | 15 | _No projects found._ 16 | " 17 | `; 18 | 19 | exports[`get-overview tool > project overview (with projectId) > should generate detailed project overview with tasks 1`] = ` 20 | "# test-abc123def456-project 21 | 22 | - id=8485093748; content=Task without section 23 | 24 | ## To Do 25 | - id=8485093749; content=Task in To Do section 26 | - id=8485093750; content=Subtask of important task 27 | 28 | ## In Progress" 29 | `; 30 | 31 | exports[`get-overview tool > project overview (with projectId) > should handle project with no tasks 1`] = `"# Empty Project"`; 32 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/update-comments.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`update-comments tool > bulk operations > should update multiple comments from different entities (task + project) 1`] = `"Updated 1 task comment and 1 project comment"`; 4 | 5 | exports[`update-comments tool > bulk operations > should update multiple comments from different tasks 1`] = `"Updated 2 task comments"`; 6 | 7 | exports[`update-comments tool > bulk operations > should update multiple comments from the same project 1`] = `"Updated 2 project comments"`; 8 | 9 | exports[`update-comments tool > bulk operations > should update multiple comments from the same task 1`] = `"Updated 2 task comments"`; 10 | 11 | exports[`update-comments tool > should handle project comment 1`] = `"Updated 1 project comment"`; 12 | 13 | exports[`update-comments tool > should update comment content 1`] = `"Updated 1 task comment"`; 14 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/update-projects.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`update-projects tool > updating a single project > should update a project when id and name are provided 1`] = ` 4 | "Updated 1 project: 5 | • Updated Project Name (id=existing-project-123)" 6 | `; 7 | 8 | exports[`update-projects tool > updating a single project > should update project with isFavorite and viewStyle options 1`] = ` 9 | "Updated 1 project: 10 | • Updated Favorite Project (id=project-123)" 11 | `; 12 | 13 | exports[`update-projects tool > updating multiple projects > should skip projects with no updates and report correctly 1`] = ` 14 | "Updated 1 project (1 skipped - no changes): 15 | • Updated Project (id=project-1)" 16 | `; 17 | 18 | exports[`update-projects tool > updating multiple projects > should update multiple projects and return mapped results 1`] = ` 19 | "Updated 3 projects: 20 | • Updated First Project (id=project-1) 21 | • Updated Second Project (id=project-2) 22 | • Updated Third Project (id=project-3)" 23 | `; 24 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/update-sections.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`update-sections tool > updating a single section > should update a section when id and name are provided 1`] = ` 4 | "Updated 1 section: 5 | • Updated Section Name (id=existing-section-123, projectId=6cfCcrrCFg2xP94Q)" 6 | `; 7 | 8 | exports[`update-sections tool > updating multiple sections > should handle sections from the same project 1`] = ` 9 | "Updated 2 sections: 10 | • Backlog (id=section-1, projectId=same-project) 11 | • Done (id=section-2, projectId=same-project)" 12 | `; 13 | 14 | exports[`update-sections tool > updating multiple sections > should update multiple sections and return mapped results 1`] = ` 15 | "Updated 3 sections: 16 | • Updated First Section (id=section-1, projectId=project-1) 17 | • Updated Second Section (id=section-2, projectId=project-1) 18 | • Updated Third Section (id=section-3, projectId=project-2)" 19 | `; 20 | -------------------------------------------------------------------------------- /src/tools/__tests__/delete-object.test.ts: -------------------------------------------------------------------------------- 1 | import type { TodoistApi } from '@doist/todoist-api-typescript' 2 | import { type Mocked, vi } from 'vitest' 3 | import { extractTextContent } from '../../utils/test-helpers.js' 4 | import { ToolNames } from '../../utils/tool-names.js' 5 | import { deleteObject } from '../delete-object.js' 6 | 7 | // Mock the Todoist API 8 | const mockTodoistApi = { 9 | deleteProject: vi.fn(), 10 | deleteSection: vi.fn(), 11 | deleteTask: vi.fn(), 12 | } as unknown as Mocked 13 | 14 | const { DELETE_OBJECT } = ToolNames 15 | 16 | describe(`${DELETE_OBJECT} tool`, () => { 17 | beforeEach(() => { 18 | vi.clearAllMocks() 19 | }) 20 | 21 | describe('deleting projects', () => { 22 | it('should delete a project by ID', async () => { 23 | mockTodoistApi.deleteProject.mockResolvedValue(true) 24 | 25 | const result = await deleteObject.execute( 26 | { type: 'project', id: '6cfCcrrCFg2xP94Q' }, 27 | mockTodoistApi, 28 | ) 29 | 30 | expect(mockTodoistApi.deleteProject).toHaveBeenCalledWith('6cfCcrrCFg2xP94Q') 31 | expect(mockTodoistApi.deleteSection).not.toHaveBeenCalled() 32 | expect(mockTodoistApi.deleteTask).not.toHaveBeenCalled() 33 | 34 | const textContent = extractTextContent(result) 35 | expect(textContent).toMatchSnapshot() 36 | expect(textContent).toContain('Deleted project: id=6cfCcrrCFg2xP94Q') 37 | expect(result.structuredContent).toEqual({ 38 | deletedEntity: { 39 | type: 'project', 40 | id: '6cfCcrrCFg2xP94Q', 41 | }, 42 | success: true, 43 | }) 44 | }) 45 | 46 | it('should propagate project deletion errors', async () => { 47 | const apiError = new Error('API Error: Cannot delete project with tasks') 48 | mockTodoistApi.deleteProject.mockRejectedValue(apiError) 49 | 50 | await expect( 51 | deleteObject.execute({ type: 'project', id: 'project-with-tasks' }, mockTodoistApi), 52 | ).rejects.toThrow('API Error: Cannot delete project with tasks') 53 | }) 54 | }) 55 | 56 | describe('deleting sections', () => { 57 | it('should delete a section by ID', async () => { 58 | mockTodoistApi.deleteSection.mockResolvedValue(true) 59 | 60 | const result = await deleteObject.execute( 61 | { type: 'section', id: 'section-123' }, 62 | mockTodoistApi, 63 | ) 64 | 65 | expect(mockTodoistApi.deleteSection).toHaveBeenCalledWith('section-123') 66 | expect(mockTodoistApi.deleteProject).not.toHaveBeenCalled() 67 | expect(mockTodoistApi.deleteTask).not.toHaveBeenCalled() 68 | 69 | const textContent = extractTextContent(result) 70 | expect(textContent).toMatchSnapshot() 71 | expect(textContent).toContain('Deleted section: id=section-123') 72 | expect(result.structuredContent).toEqual({ 73 | deletedEntity: { type: 'section', id: 'section-123' }, 74 | success: true, 75 | }) 76 | }) 77 | 78 | it('should propagate section deletion errors', async () => { 79 | const apiError = new Error('API Error: Section not found') 80 | mockTodoistApi.deleteSection.mockRejectedValue(apiError) 81 | 82 | await expect( 83 | deleteObject.execute( 84 | { type: 'section', id: 'non-existent-section' }, 85 | mockTodoistApi, 86 | ), 87 | ).rejects.toThrow('API Error: Section not found') 88 | }) 89 | }) 90 | 91 | describe('deleting tasks', () => { 92 | it('should delete a task by ID', async () => { 93 | mockTodoistApi.deleteTask.mockResolvedValue(true) 94 | 95 | const result = await deleteObject.execute( 96 | { type: 'task', id: '8485093748' }, 97 | mockTodoistApi, 98 | ) 99 | 100 | expect(mockTodoistApi.deleteTask).toHaveBeenCalledWith('8485093748') 101 | expect(mockTodoistApi.deleteProject).not.toHaveBeenCalled() 102 | expect(mockTodoistApi.deleteSection).not.toHaveBeenCalled() 103 | 104 | const textContent = extractTextContent(result) 105 | expect(textContent).toMatchSnapshot() 106 | expect(textContent).toContain('Deleted task: id=8485093748') 107 | expect(result.structuredContent).toEqual({ 108 | deletedEntity: { type: 'task', id: '8485093748' }, 109 | success: true, 110 | }) 111 | }) 112 | 113 | it('should propagate task deletion errors', async () => { 114 | const apiError = new Error('API Error: Task not found') 115 | mockTodoistApi.deleteTask.mockRejectedValue(apiError) 116 | 117 | await expect( 118 | deleteObject.execute({ type: 'task', id: 'non-existent-task' }, mockTodoistApi), 119 | ).rejects.toThrow('API Error: Task not found') 120 | }) 121 | 122 | it('should handle permission errors', async () => { 123 | const apiError = new Error('API Error: Insufficient permissions to delete task') 124 | mockTodoistApi.deleteTask.mockRejectedValue(apiError) 125 | 126 | await expect( 127 | deleteObject.execute({ type: 'task', id: 'restricted-task' }, mockTodoistApi), 128 | ).rejects.toThrow('API Error: Insufficient permissions to delete task') 129 | }) 130 | }) 131 | 132 | describe('type validation', () => { 133 | it('should handle all supported entity types', async () => { 134 | mockTodoistApi.deleteProject.mockResolvedValue(true) 135 | mockTodoistApi.deleteSection.mockResolvedValue(true) 136 | mockTodoistApi.deleteTask.mockResolvedValue(true) 137 | 138 | // Delete project 139 | await deleteObject.execute({ type: 'project', id: 'proj-1' }, mockTodoistApi) 140 | expect(mockTodoistApi.deleteProject).toHaveBeenCalledWith('proj-1') 141 | 142 | // Delete section 143 | await deleteObject.execute({ type: 'section', id: 'sect-1' }, mockTodoistApi) 144 | expect(mockTodoistApi.deleteSection).toHaveBeenCalledWith('sect-1') 145 | 146 | // Delete task 147 | await deleteObject.execute({ type: 'task', id: 'task-1' }, mockTodoistApi) 148 | expect(mockTodoistApi.deleteTask).toHaveBeenCalledWith('task-1') 149 | 150 | // Verify each API method was called exactly once 151 | expect(mockTodoistApi.deleteProject).toHaveBeenCalledTimes(1) 152 | expect(mockTodoistApi.deleteSection).toHaveBeenCalledTimes(1) 153 | expect(mockTodoistApi.deleteTask).toHaveBeenCalledTimes(1) 154 | }) 155 | }) 156 | }) 157 | -------------------------------------------------------------------------------- /src/tools/__tests__/find-projects.test.ts: -------------------------------------------------------------------------------- 1 | import type { TodoistApi } from '@doist/todoist-api-typescript' 2 | import { type Mocked, vi } from 'vitest' 3 | import { 4 | createMockApiResponse, 5 | createMockProject, 6 | extractStructuredContent, 7 | extractTextContent, 8 | TEST_ERRORS, 9 | TEST_IDS, 10 | } from '../../utils/test-helpers.js' 11 | import { ToolNames } from '../../utils/tool-names.js' 12 | import { findProjects } from '../find-projects.js' 13 | 14 | // Mock the Todoist API 15 | const mockTodoistApi = { 16 | getProjects: vi.fn(), 17 | } as unknown as Mocked 18 | 19 | const { FIND_PROJECTS } = ToolNames 20 | 21 | describe(`${FIND_PROJECTS} tool`, () => { 22 | beforeEach(() => { 23 | vi.clearAllMocks() 24 | }) 25 | 26 | describe('listing all projects', () => { 27 | it('should list all projects when no search parameter is provided', async () => { 28 | const mockProjects = [ 29 | createMockProject({ 30 | id: TEST_IDS.PROJECT_INBOX, 31 | name: 'Inbox', 32 | color: 'grey', 33 | inboxProject: true, 34 | childOrder: 0, 35 | }), 36 | createMockProject({ 37 | id: TEST_IDS.PROJECT_TEST, 38 | name: 'test-abc123def456-project', 39 | color: 'charcoal', 40 | childOrder: 1, 41 | }), 42 | createMockProject({ 43 | id: TEST_IDS.PROJECT_WORK, 44 | name: 'Work Project', 45 | color: 'blue', 46 | isFavorite: true, 47 | isShared: true, 48 | viewStyle: 'board', 49 | childOrder: 2, 50 | description: 'Important work tasks', 51 | canAssignTasks: true, 52 | }), 53 | ] 54 | 55 | mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects)) 56 | 57 | const result = await findProjects.execute({ limit: 50 }, mockTodoistApi) 58 | 59 | // Verify API was called correctly 60 | expect(mockTodoistApi.getProjects).toHaveBeenCalledWith({ 61 | limit: 50, 62 | cursor: null, 63 | }) 64 | 65 | expect(extractTextContent(result)).toMatchSnapshot() 66 | 67 | // Verify structured content 68 | const structuredContent = extractStructuredContent(result) 69 | expect(structuredContent).toEqual( 70 | expect.objectContaining({ 71 | projects: expect.any(Array), 72 | totalCount: 3, 73 | hasMore: false, 74 | appliedFilters: { 75 | search: undefined, 76 | limit: 50, 77 | cursor: undefined, 78 | }, 79 | }), 80 | ) 81 | expect(structuredContent.projects).toHaveLength(3) 82 | }) 83 | 84 | it('should handle pagination with limit and cursor', async () => { 85 | const mockProject = createMockProject({ 86 | id: 'project-1', 87 | name: 'First Project', 88 | color: 'red', 89 | }) 90 | mockTodoistApi.getProjects.mockResolvedValue( 91 | createMockApiResponse([mockProject], 'next-page-cursor'), 92 | ) 93 | 94 | const result = await findProjects.execute( 95 | { limit: 10, cursor: 'current-page-cursor' }, 96 | mockTodoistApi, 97 | ) 98 | 99 | expect(mockTodoistApi.getProjects).toHaveBeenCalledWith({ 100 | limit: 10, 101 | cursor: 'current-page-cursor', 102 | }) 103 | expect(extractTextContent(result)).toMatchSnapshot() 104 | 105 | // Verify structured content 106 | const structuredContent = extractStructuredContent(result) 107 | expect(structuredContent.projects).toHaveLength(1) 108 | expect(structuredContent.totalCount).toBe(1) 109 | expect(structuredContent.hasMore).toBe(true) 110 | expect(structuredContent.nextCursor).toBe('next-page-cursor') 111 | expect(structuredContent.appliedFilters).toEqual({ 112 | search: undefined, 113 | limit: 10, 114 | cursor: 'current-page-cursor', 115 | }) 116 | }) 117 | }) 118 | 119 | describe('searching projects', () => { 120 | it('should filter projects by search term (case insensitive)', async () => { 121 | const mockProjects = [ 122 | createMockProject({ 123 | id: TEST_IDS.PROJECT_WORK, 124 | name: 'Work Project', 125 | color: 'blue', 126 | }), 127 | createMockProject({ 128 | id: 'personal-project-id', 129 | name: 'Personal Tasks', 130 | color: 'green', 131 | }), 132 | createMockProject({ id: 'hobby-project-id', name: 'Hobby Work', color: 'orange' }), 133 | ] 134 | 135 | mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects)) 136 | const result = await findProjects.execute({ search: 'work', limit: 50 }, mockTodoistApi) 137 | 138 | expect(mockTodoistApi.getProjects).toHaveBeenCalledWith({ limit: 50, cursor: null }) 139 | expect(extractTextContent(result)).toMatchSnapshot() 140 | 141 | // Verify structured content with search filter 142 | const structuredContent = extractStructuredContent(result) 143 | expect(structuredContent.projects).toHaveLength(2) // Should match filtered results 144 | expect(structuredContent.totalCount).toBe(2) 145 | expect(structuredContent.hasMore).toBe(false) 146 | expect(structuredContent.appliedFilters).toEqual({ 147 | search: 'work', 148 | limit: 50, 149 | cursor: undefined, 150 | }) 151 | }) 152 | 153 | it.each([ 154 | { 155 | search: 'nonexistent', 156 | projects: ['Project One'], 157 | expectedCount: 0, 158 | description: 'no matches', 159 | }, 160 | { 161 | search: 'IMPORTANT', 162 | projects: ['Important Project'], 163 | expectedCount: 1, 164 | description: 'case insensitive matching', 165 | }, 166 | ])('should handle search with $description', async ({ search, projects }) => { 167 | const mockProjects = projects.map((name) => createMockProject({ name })) 168 | mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects)) 169 | 170 | const result = await findProjects.execute({ search, limit: 50 }, mockTodoistApi) 171 | expect(extractTextContent(result)).toMatchSnapshot() 172 | 173 | // Verify structured content 174 | const structuredContent = extractStructuredContent(result) 175 | expect(structuredContent).toEqual( 176 | expect.objectContaining({ 177 | appliedFilters: expect.objectContaining({ search }), 178 | }), 179 | ) 180 | }) 181 | }) 182 | 183 | describe('error handling', () => { 184 | it.each([ 185 | { error: TEST_ERRORS.API_UNAUTHORIZED, params: { limit: 50 } }, 186 | { error: TEST_ERRORS.INVALID_CURSOR, params: { cursor: 'invalid-cursor', limit: 50 } }, 187 | ])('should propagate $error', async ({ error, params }) => { 188 | mockTodoistApi.getProjects.mockRejectedValue(new Error(error)) 189 | await expect(findProjects.execute(params, mockTodoistApi)).rejects.toThrow(error) 190 | }) 191 | }) 192 | }) 193 | -------------------------------------------------------------------------------- /src/tools/__tests__/user-info.test.ts: -------------------------------------------------------------------------------- 1 | import type { CurrentUser, TodoistApi } from '@doist/todoist-api-typescript' 2 | import { type Mocked, vi } from 'vitest' 3 | import { 4 | extractStructuredContent, 5 | extractTextContent, 6 | TEST_ERRORS, 7 | } from '../../utils/test-helpers.js' 8 | import { ToolNames } from '../../utils/tool-names.js' 9 | import { userInfo } from '../user-info.js' 10 | 11 | // Mock the Todoist API 12 | const mockTodoistApi = { 13 | getUser: vi.fn(), 14 | } as unknown as Mocked 15 | 16 | const { USER_INFO } = ToolNames 17 | 18 | // Helper function to create a mock user with default values that can be overridden 19 | function createMockUser(overrides: Partial = {}): CurrentUser { 20 | return { 21 | id: '123', 22 | fullName: 'Test User', 23 | email: 'test@example.com', 24 | isPremium: true, 25 | completedToday: 12, 26 | dailyGoal: 10, 27 | weeklyGoal: 100, 28 | startDay: 1, // Monday 29 | tzInfo: { 30 | timezone: 'Europe/Madrid', 31 | gmtString: '+02:00', 32 | hours: 2, 33 | minutes: 0, 34 | isDst: 1, 35 | }, 36 | lang: 'en', 37 | avatarBig: 'https://example.com/avatar.jpg', 38 | avatarMedium: null, 39 | avatarS640: null, 40 | avatarSmall: null, 41 | karma: 86394.0, 42 | karmaTrend: 'up', 43 | nextWeek: 1, 44 | weekendStartDay: 6, 45 | timeFormat: 0, 46 | dateFormat: 0, 47 | daysOff: [6, 7], 48 | businessAccountId: null, 49 | completedCount: 102920, 50 | inboxProjectId: '6PVw8cMf7m8fWwRp', 51 | startPage: 'overdue', 52 | ...overrides, 53 | } 54 | } 55 | 56 | describe(`${USER_INFO} tool`, () => { 57 | beforeEach(() => { 58 | vi.clearAllMocks() 59 | }) 60 | 61 | it('should generate user info with all required fields', async () => { 62 | const mockUser = createMockUser() 63 | 64 | mockTodoistApi.getUser.mockResolvedValue(mockUser) 65 | 66 | const result = await userInfo.execute({}, mockTodoistApi) 67 | 68 | expect(mockTodoistApi.getUser).toHaveBeenCalledWith() 69 | 70 | // Test text content contains expected information 71 | const textContent = extractTextContent(result) 72 | expect(textContent).toContain('User ID:** 123') 73 | expect(textContent).toContain('Test User') 74 | expect(textContent).toContain('test@example.com') 75 | expect(textContent).toContain('Europe/Madrid') 76 | expect(textContent).toContain('Monday (1)') 77 | expect(textContent).toContain('Completed Today:** 12') 78 | expect(textContent).toContain('Plan:** Todoist Pro') 79 | 80 | // Test structured content 81 | const structuredContent = extractStructuredContent(result) 82 | expect(structuredContent).toEqual( 83 | expect.objectContaining({ 84 | type: 'user_info', 85 | userId: '123', 86 | fullName: 'Test User', 87 | email: 'test@example.com', 88 | timezone: 'Europe/Madrid', 89 | startDay: 1, 90 | startDayName: 'Monday', 91 | completedToday: 12, 92 | dailyGoal: 10, 93 | weeklyGoal: 100, 94 | plan: 'Todoist Pro', 95 | currentLocalTime: expect.any(String), 96 | weekStartDate: expect.any(String), 97 | weekEndDate: expect.any(String), 98 | currentWeekNumber: expect.any(Number), 99 | }), 100 | ) 101 | 102 | // Verify date formats 103 | expect(structuredContent.weekStartDate).toMatch(/^\d{4}-\d{2}-\d{2}$/) 104 | expect(structuredContent.weekEndDate).toMatch(/^\d{4}-\d{2}-\d{2}$/) 105 | expect(structuredContent.currentLocalTime).toMatch( 106 | /^\d{2}\/\d{2}\/\d{4}, \d{2}:\d{2}:\d{2}$/, 107 | ) 108 | }) 109 | 110 | it('should handle missing timezone info', async () => { 111 | const mockUser = createMockUser({ 112 | isPremium: false, 113 | tzInfo: { 114 | timezone: 'UTC', 115 | gmtString: '+00:00', 116 | hours: 0, 117 | minutes: 0, 118 | isDst: 0, 119 | }, 120 | }) 121 | 122 | mockTodoistApi.getUser.mockResolvedValue(mockUser) 123 | 124 | const result = await userInfo.execute({}, mockTodoistApi) 125 | 126 | const textContent = extractTextContent(result) 127 | expect(textContent).toContain('UTC') // Should default to UTC 128 | expect(textContent).toContain('Monday (1)') // Should default to Monday 129 | expect(textContent).toContain('Plan:** Todoist Free') 130 | 131 | const structuredContent = extractStructuredContent(result) 132 | expect(structuredContent.timezone).toBe('UTC') 133 | expect(structuredContent.startDay).toBe(1) 134 | expect(structuredContent.startDayName).toBe('Monday') 135 | expect(structuredContent.plan).toBe('Todoist Free') 136 | }) 137 | 138 | it('should handle invalid timezone and fallback to UTC', async () => { 139 | const mockUser = createMockUser({ 140 | startDay: 2, // Tuesday 141 | tzInfo: { 142 | timezone: 'Invalid/Timezone', 143 | gmtString: '+05:30', 144 | hours: 5, 145 | minutes: 30, 146 | isDst: 0, 147 | }, 148 | }) 149 | 150 | mockTodoistApi.getUser.mockResolvedValue(mockUser) 151 | 152 | const result = await userInfo.execute({}, mockTodoistApi) 153 | 154 | const textContent = extractTextContent(result) 155 | expect(textContent).toContain('UTC') // Should fallback to UTC 156 | expect(textContent).toContain('Tuesday (2)') 157 | 158 | const structuredContent = extractStructuredContent(result) 159 | expect(structuredContent.timezone).toBe('UTC') // Should be UTC, not the invalid timezone 160 | expect(structuredContent.startDay).toBe(2) 161 | expect(structuredContent.startDayName).toBe('Tuesday') 162 | expect(structuredContent.currentLocalTime).toMatch( 163 | /^\d{2}\/\d{2}\/\d{4}, \d{2}:\d{2}:\d{2}$/, 164 | ) 165 | }) 166 | 167 | it('should propagate API errors', async () => { 168 | const apiError = new Error(TEST_ERRORS.API_UNAUTHORIZED) 169 | mockTodoistApi.getUser.mockRejectedValue(apiError) 170 | 171 | await expect(userInfo.execute({}, mockTodoistApi)).rejects.toThrow( 172 | TEST_ERRORS.API_UNAUTHORIZED, 173 | ) 174 | }) 175 | }) 176 | -------------------------------------------------------------------------------- /src/tools/add-comments.ts: -------------------------------------------------------------------------------- 1 | import type { AddCommentArgs, Comment } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { CommentSchema as CommentOutputSchema } from '../utils/output-schemas.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const CommentSchema = z.object({ 9 | taskId: z.string().optional().describe('The ID of the task to comment on.'), 10 | projectId: z 11 | .string() 12 | .optional() 13 | .describe( 14 | 'The ID of the project to comment on. Project ID should be an ID string, or the text "inbox", for inbox tasks.', 15 | ), 16 | content: z.string().min(1).describe('The content of the comment.'), 17 | }) 18 | 19 | const ArgsSchema = { 20 | comments: z.array(CommentSchema).min(1).describe('The array of comments to add.'), 21 | } 22 | 23 | const OutputSchema = { 24 | comments: z.array(CommentOutputSchema).describe('The created comments.'), 25 | totalCount: z.number().describe('The total number of comments created.'), 26 | addedCommentIds: z.array(z.string()).describe('The IDs of the added comments.'), 27 | } 28 | 29 | const addComments = { 30 | name: ToolNames.ADD_COMMENTS, 31 | description: 32 | 'Add multiple comments to tasks or projects. Each comment must specify either taskId or projectId.', 33 | parameters: ArgsSchema, 34 | outputSchema: OutputSchema, 35 | async execute(args, client) { 36 | const { comments } = args 37 | 38 | // Validate each comment 39 | for (const [index, comment] of comments.entries()) { 40 | if (!comment.taskId && !comment.projectId) { 41 | throw new Error( 42 | `Comment ${index + 1}: Either taskId or projectId must be provided.`, 43 | ) 44 | } 45 | if (comment.taskId && comment.projectId) { 46 | throw new Error( 47 | `Comment ${index + 1}: Cannot provide both taskId and projectId. Choose one.`, 48 | ) 49 | } 50 | } 51 | 52 | // Check if any comment needs inbox resolution 53 | const needsInboxResolution = comments.some((comment) => comment.projectId === 'inbox') 54 | const todoistUser = needsInboxResolution ? await client.getUser() : null 55 | 56 | const addCommentPromises = comments.map(async ({ content, taskId, projectId }) => { 57 | // Resolve "inbox" to actual inbox project ID if needed 58 | const resolvedProjectId = 59 | projectId === 'inbox' && todoistUser ? todoistUser.inboxProjectId : projectId 60 | 61 | return await client.addComment({ 62 | content, 63 | ...(taskId ? { taskId } : { projectId: resolvedProjectId }), 64 | } as AddCommentArgs) 65 | }) 66 | 67 | const newComments = await Promise.all(addCommentPromises) 68 | const textContent = generateTextContent({ comments: newComments }) 69 | 70 | return getToolOutput({ 71 | textContent, 72 | structuredContent: { 73 | comments: newComments, 74 | totalCount: newComments.length, 75 | addedCommentIds: newComments.map((comment) => comment.id), 76 | }, 77 | }) 78 | }, 79 | } satisfies TodoistTool 80 | 81 | function generateTextContent({ comments }: { comments: Comment[] }): string { 82 | // Group comments by entity type and count 83 | const taskComments = comments.filter((c) => c.taskId).length 84 | const projectComments = comments.filter((c) => c.projectId).length 85 | 86 | // Generate summary text 87 | const parts: string[] = [] 88 | if (taskComments > 0) { 89 | const commentsLabel = taskComments > 1 ? 'comments' : 'comment' 90 | parts.push(`${taskComments} task ${commentsLabel}`) 91 | } 92 | if (projectComments > 0) { 93 | const commentsLabel = projectComments > 1 ? 'comments' : 'comment' 94 | parts.push(`${projectComments} project ${commentsLabel}`) 95 | } 96 | const summary = parts.length > 0 ? `Added ${parts.join(' and ')}` : 'No comments added' 97 | 98 | return summary 99 | } 100 | 101 | export { addComments } 102 | -------------------------------------------------------------------------------- /src/tools/add-projects.ts: -------------------------------------------------------------------------------- 1 | import type { PersonalProject, WorkspaceProject } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { ProjectSchema as ProjectOutputSchema } from '../utils/output-schemas.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const ProjectSchema = z.object({ 9 | name: z.string().min(1).describe('The name of the project.'), 10 | parentId: z 11 | .string() 12 | .optional() 13 | .describe('The ID of the parent project. If provided, creates this as a sub-project.'), 14 | isFavorite: z 15 | .boolean() 16 | .optional() 17 | .describe('Whether the project is a favorite. Defaults to false.'), 18 | viewStyle: z 19 | .enum(['list', 'board', 'calendar']) 20 | .optional() 21 | .describe('The project view style. Defaults to "list".'), 22 | }) 23 | 24 | const ArgsSchema = { 25 | projects: z.array(ProjectSchema).min(1).describe('The array of projects to add.'), 26 | } 27 | 28 | const OutputSchema = { 29 | projects: z.array(ProjectOutputSchema).describe('The created projects.'), 30 | totalCount: z.number().describe('The total number of projects created.'), 31 | } 32 | 33 | const addProjects = { 34 | name: ToolNames.ADD_PROJECTS, 35 | description: 'Add one or more new projects.', 36 | parameters: ArgsSchema, 37 | outputSchema: OutputSchema, 38 | async execute({ projects }, client) { 39 | const newProjects = await Promise.all(projects.map((project) => client.addProject(project))) 40 | const textContent = generateTextContent({ projects: newProjects }) 41 | 42 | return getToolOutput({ 43 | textContent, 44 | structuredContent: { 45 | projects: newProjects, 46 | totalCount: newProjects.length, 47 | }, 48 | }) 49 | }, 50 | } satisfies TodoistTool 51 | 52 | function generateTextContent({ projects }: { projects: (PersonalProject | WorkspaceProject)[] }) { 53 | const count = projects.length 54 | const projectList = projects.map((project) => `• ${project.name} (id=${project.id})`).join('\n') 55 | 56 | const summary = `Added ${count} project${count === 1 ? '' : 's'}:\n${projectList}` 57 | 58 | return summary 59 | } 60 | 61 | export { addProjects } 62 | -------------------------------------------------------------------------------- /src/tools/add-sections.ts: -------------------------------------------------------------------------------- 1 | import type { Section } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { SectionSchema as SectionOutputSchema } from '../utils/output-schemas.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const SectionSchema = z.object({ 9 | name: z.string().min(1).describe('The name of the section.'), 10 | projectId: z 11 | .string() 12 | .min(1) 13 | .describe( 14 | 'The ID of the project to add the section to. Project ID should be an ID string, or the text "inbox", for inbox tasks.', 15 | ), 16 | }) 17 | 18 | const ArgsSchema = { 19 | sections: z.array(SectionSchema).min(1).describe('The array of sections to add.'), 20 | } 21 | 22 | const OutputSchema = { 23 | sections: z.array(SectionOutputSchema).describe('The created sections.'), 24 | totalCount: z.number().describe('The total number of sections created.'), 25 | } 26 | 27 | const addSections = { 28 | name: ToolNames.ADD_SECTIONS, 29 | description: 'Add one or more new sections to projects.', 30 | parameters: ArgsSchema, 31 | outputSchema: OutputSchema, 32 | async execute({ sections }, client) { 33 | // Check if any section needs inbox resolution 34 | const needsInboxResolution = sections.some((section) => section.projectId === 'inbox') 35 | const todoistUser = needsInboxResolution ? await client.getUser() : null 36 | 37 | // Resolve inbox project IDs 38 | const sectionsWithResolvedProjectIds = sections.map((section) => ({ 39 | ...section, 40 | projectId: 41 | section.projectId === 'inbox' && todoistUser 42 | ? todoistUser.inboxProjectId 43 | : section.projectId, 44 | })) 45 | 46 | const newSections = await Promise.all( 47 | sectionsWithResolvedProjectIds.map((section) => client.addSection(section)), 48 | ) 49 | const textContent = generateTextContent({ sections: newSections }) 50 | 51 | return getToolOutput({ 52 | textContent, 53 | structuredContent: { 54 | sections: newSections, 55 | totalCount: newSections.length, 56 | }, 57 | }) 58 | }, 59 | } satisfies TodoistTool 60 | 61 | function generateTextContent({ sections }: { sections: Section[] }) { 62 | const count = sections.length 63 | const sectionList = sections 64 | .map((section) => `• ${section.name} (id=${section.id}, projectId=${section.projectId})`) 65 | .join('\n') 66 | 67 | const summary = `Added ${count} section${count === 1 ? '' : 's'}:\n${sectionList}` 68 | 69 | return summary 70 | } 71 | 72 | export { addSections } 73 | -------------------------------------------------------------------------------- /src/tools/complete-tasks.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { getToolOutput } from '../mcp-helpers.js' 3 | import type { TodoistTool } from '../todoist-tool.js' 4 | import { FailureSchema } from '../utils/output-schemas.js' 5 | import { summarizeBatch } from '../utils/response-builders.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const ArgsSchema = { 9 | ids: z.array(z.string().min(1)).min(1).describe('The IDs of the tasks to complete.'), 10 | } 11 | 12 | const OutputSchema = { 13 | completed: z.array(z.string()).describe('The IDs of successfully completed tasks.'), 14 | failures: z.array(FailureSchema).describe('Failed task completions with error details.'), 15 | totalRequested: z.number().describe('The total number of tasks requested to complete.'), 16 | successCount: z.number().describe('The number of successfully completed tasks.'), 17 | failureCount: z.number().describe('The number of failed task completions.'), 18 | } 19 | 20 | const completeTasks = { 21 | name: ToolNames.COMPLETE_TASKS, 22 | description: 'Complete one or more tasks by their IDs.', 23 | parameters: ArgsSchema, 24 | outputSchema: OutputSchema, 25 | async execute(args, client) { 26 | const completed: string[] = [] 27 | const failures: Array<{ item: string; error: string; code?: string }> = [] 28 | 29 | for (const id of args.ids) { 30 | try { 31 | await client.closeTask(id) 32 | completed.push(id) 33 | } catch (error) { 34 | const errorMessage = error instanceof Error ? error.message : 'Unknown error' 35 | failures.push({ 36 | item: id, 37 | error: errorMessage, 38 | }) 39 | } 40 | } 41 | 42 | const textContent = generateTextContent({ 43 | completed, 44 | failures, 45 | args, 46 | }) 47 | 48 | return getToolOutput({ 49 | textContent, 50 | structuredContent: { 51 | completed, 52 | failures, 53 | totalRequested: args.ids.length, 54 | successCount: completed.length, 55 | failureCount: failures.length, 56 | }, 57 | }) 58 | }, 59 | } satisfies TodoistTool 60 | 61 | function generateTextContent({ 62 | completed, 63 | failures, 64 | args, 65 | }: { 66 | completed: string[] 67 | failures: Array<{ item: string; error: string; code?: string }> 68 | args: z.infer> 69 | }) { 70 | return summarizeBatch({ 71 | action: 'Completed tasks', 72 | success: completed.length, 73 | total: args.ids.length, 74 | successItems: completed, 75 | failures, 76 | }) 77 | } 78 | 79 | export { completeTasks } 80 | -------------------------------------------------------------------------------- /src/tools/delete-object.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { getToolOutput } from '../mcp-helpers.js' 3 | import type { TodoistTool } from '../todoist-tool.js' 4 | import { ToolNames } from '../utils/tool-names.js' 5 | 6 | const ArgsSchema = { 7 | type: z 8 | .enum(['project', 'section', 'task', 'comment']) 9 | .describe('The type of entity to delete.'), 10 | id: z.string().min(1).describe('The ID of the entity to delete.'), 11 | } 12 | 13 | const OutputSchema = { 14 | deletedEntity: z 15 | .object({ 16 | type: z 17 | .enum(['project', 'section', 'task', 'comment']) 18 | .describe('The type of deleted entity.'), 19 | id: z.string().describe('The ID of the deleted entity.'), 20 | }) 21 | .describe('Information about the deleted entity.'), 22 | success: z.boolean().describe('Whether the deletion was successful.'), 23 | } 24 | 25 | const deleteObject = { 26 | name: ToolNames.DELETE_OBJECT, 27 | description: 'Delete a project, section, task, or comment by its ID.', 28 | parameters: ArgsSchema, 29 | outputSchema: OutputSchema, 30 | async execute(args, client) { 31 | switch (args.type) { 32 | case 'project': 33 | await client.deleteProject(args.id) 34 | break 35 | case 'section': 36 | await client.deleteSection(args.id) 37 | break 38 | case 'task': 39 | await client.deleteTask(args.id) 40 | break 41 | case 'comment': 42 | await client.deleteComment(args.id) 43 | break 44 | } 45 | 46 | const textContent = generateTextContent({ 47 | type: args.type, 48 | id: args.id, 49 | }) 50 | 51 | return getToolOutput({ 52 | textContent, 53 | structuredContent: { 54 | deletedEntity: { 55 | type: args.type, 56 | id: args.id, 57 | }, 58 | success: true, 59 | }, 60 | }) 61 | }, 62 | } satisfies TodoistTool 63 | 64 | function generateTextContent({ 65 | type, 66 | id, 67 | }: { 68 | type: 'project' | 'section' | 'task' | 'comment' 69 | id: string 70 | }): string { 71 | const summary = `Deleted ${type}: id=${id}` 72 | 73 | return summary 74 | } 75 | 76 | export { deleteObject } 77 | -------------------------------------------------------------------------------- /src/tools/fetch.ts: -------------------------------------------------------------------------------- 1 | import { getProjectUrl, getTaskUrl } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getErrorOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { mapProject, mapTask } from '../tool-helpers.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const ArgsSchema = { 9 | id: z 10 | .string() 11 | .min(1) 12 | .describe( 13 | 'A unique identifier for the document in the format "task:{id}" or "project:{id}".', 14 | ), 15 | } 16 | 17 | type FetchResult = { 18 | id: string 19 | title: string 20 | text: string 21 | url: string 22 | metadata?: Record 23 | } 24 | 25 | type FetchToolOutput = { 26 | content: { type: 'text'; text: string }[] 27 | isError?: boolean 28 | } 29 | 30 | const OutputSchema = { 31 | id: z.string().describe('The ID of the fetched document.'), 32 | title: z.string().describe('The title of the document.'), 33 | text: z.string().describe('The text content of the document.'), 34 | url: z.string().describe('The URL of the document.'), 35 | metadata: z.record(z.unknown()).optional().describe('Additional metadata about the document.'), 36 | } 37 | 38 | /** 39 | * OpenAI MCP fetch tool - retrieves the full contents of a task or project by ID. 40 | * 41 | * This tool follows the OpenAI MCP fetch tool specification: 42 | * @see https://platform.openai.com/docs/mcp#fetch-tool 43 | */ 44 | const fetch = { 45 | name: ToolNames.FETCH, 46 | description: 47 | 'Fetch the full contents of a task or project by its ID. The ID should be in the format "task:{id}" or "project:{id}".', 48 | parameters: ArgsSchema, 49 | outputSchema: OutputSchema, 50 | async execute(args, client): Promise { 51 | try { 52 | const { id } = args 53 | 54 | // Parse the composite ID 55 | const [type, objectId] = id.split(':', 2) 56 | 57 | if (!objectId || (type !== 'task' && type !== 'project')) { 58 | throw new Error( 59 | 'Invalid ID format. Expected "task:{id}" or "project:{id}". Example: "task:8485093748" or "project:6cfCcrrCFg2xP94Q"', 60 | ) 61 | } 62 | 63 | let result: FetchResult 64 | 65 | if (type === 'task') { 66 | // Fetch task 67 | const task = await client.getTask(objectId) 68 | const mappedTask = mapTask(task) 69 | 70 | // Build text content 71 | const textParts = [mappedTask.content] 72 | if (mappedTask.description) { 73 | textParts.push(`\n\nDescription: ${mappedTask.description}`) 74 | } 75 | if (mappedTask.dueDate) { 76 | textParts.push(`\nDue: ${mappedTask.dueDate}`) 77 | } 78 | if (mappedTask.labels.length > 0) { 79 | textParts.push(`\nLabels: ${mappedTask.labels.join(', ')}`) 80 | } 81 | 82 | result = { 83 | id: `task:${mappedTask.id}`, 84 | title: mappedTask.content, 85 | text: textParts.join(''), 86 | url: getTaskUrl(mappedTask.id), 87 | metadata: { 88 | priority: mappedTask.priority, 89 | projectId: mappedTask.projectId, 90 | sectionId: mappedTask.sectionId, 91 | parentId: mappedTask.parentId, 92 | recurring: mappedTask.recurring, 93 | duration: mappedTask.duration, 94 | responsibleUid: mappedTask.responsibleUid, 95 | assignedByUid: mappedTask.assignedByUid, 96 | checked: mappedTask.checked, 97 | completedAt: mappedTask.completedAt, 98 | }, 99 | } 100 | } else { 101 | // Fetch project 102 | const project = await client.getProject(objectId) 103 | const mappedProject = mapProject(project) 104 | 105 | // Build text content 106 | const textParts = [mappedProject.name] 107 | if (mappedProject.isShared) { 108 | textParts.push('\n\nShared project') 109 | } 110 | if (mappedProject.isFavorite) { 111 | textParts.push('\nFavorite: Yes') 112 | } 113 | 114 | result = { 115 | id: `project:${mappedProject.id}`, 116 | title: mappedProject.name, 117 | text: textParts.join(''), 118 | url: getProjectUrl(mappedProject.id), 119 | metadata: { 120 | color: mappedProject.color, 121 | isFavorite: mappedProject.isFavorite, 122 | isShared: mappedProject.isShared, 123 | parentId: mappedProject.parentId, 124 | inboxProject: mappedProject.inboxProject, 125 | viewStyle: mappedProject.viewStyle, 126 | }, 127 | } 128 | } 129 | 130 | // Return as JSON-encoded string in a text content item (OpenAI MCP spec) 131 | const jsonText = JSON.stringify(result) 132 | return { content: [{ type: 'text' as const, text: jsonText }] } 133 | } catch (error) { 134 | const message = error instanceof Error ? error.message : 'An unknown error occurred' 135 | return getErrorOutput(message) 136 | } 137 | }, 138 | } satisfies TodoistTool 139 | 140 | export { fetch } 141 | -------------------------------------------------------------------------------- /src/tools/find-comments.ts: -------------------------------------------------------------------------------- 1 | import type { Comment } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { ApiLimits } from '../utils/constants.js' 6 | import { CommentSchema as CommentOutputSchema } from '../utils/output-schemas.js' 7 | import { formatNextSteps } from '../utils/response-builders.js' 8 | import { ToolNames } from '../utils/tool-names.js' 9 | 10 | const ArgsSchema = { 11 | taskId: z.string().optional().describe('Find comments for a specific task.'), 12 | projectId: z 13 | .string() 14 | .optional() 15 | .describe( 16 | 'Find comments for a specific project. Project ID should be an ID string, or the text "inbox", for inbox tasks.', 17 | ), 18 | commentId: z.string().optional().describe('Get a specific comment by ID.'), 19 | cursor: z.string().optional().describe('Pagination cursor for retrieving more results.'), 20 | limit: z 21 | .number() 22 | .int() 23 | .min(1) 24 | .max(ApiLimits.COMMENTS_MAX) 25 | .optional() 26 | .describe('Maximum number of comments to return'), 27 | } 28 | 29 | const OutputSchema = { 30 | comments: z.array(CommentOutputSchema).describe('The found comments.'), 31 | nextCursor: z.string().optional().describe('Cursor for the next page of results.'), 32 | totalCount: z.number().describe('The total number of comments in this page.'), 33 | } 34 | 35 | const findComments = { 36 | name: ToolNames.FIND_COMMENTS, 37 | description: 38 | 'Find comments by task, project, or get a specific comment by ID. Exactly one of taskId, projectId, or commentId must be provided.', 39 | parameters: ArgsSchema, 40 | outputSchema: OutputSchema, 41 | async execute(args, client) { 42 | // Validate that exactly one search parameter is provided 43 | const searchParams = [args.taskId, args.projectId, args.commentId].filter(Boolean) 44 | if (searchParams.length === 0) { 45 | throw new Error('Must provide exactly one of: taskId, projectId, or commentId.') 46 | } 47 | if (searchParams.length > 1) { 48 | throw new Error( 49 | 'Cannot provide multiple search parameters. Choose one of: taskId, projectId, or commentId.', 50 | ) 51 | } 52 | 53 | // Resolve "inbox" to actual inbox project ID if needed 54 | const resolvedProjectId = 55 | args.projectId === 'inbox' ? (await client.getUser()).inboxProjectId : args.projectId 56 | 57 | let comments: Comment[] 58 | let hasMore = false 59 | let nextCursor: string | null = null 60 | 61 | if (args.commentId) { 62 | // Get single comment 63 | const comment = await client.getComment(args.commentId) 64 | comments = [comment] 65 | } else if (args.taskId) { 66 | // Get comments by task 67 | const response = await client.getComments({ 68 | taskId: args.taskId, 69 | cursor: args.cursor || null, 70 | limit: args.limit || ApiLimits.COMMENTS_DEFAULT, 71 | }) 72 | comments = response.results 73 | hasMore = response.nextCursor !== null 74 | nextCursor = response.nextCursor 75 | } else if (resolvedProjectId) { 76 | // Get comments by project 77 | const response = await client.getComments({ 78 | projectId: resolvedProjectId, 79 | cursor: args.cursor || null, 80 | limit: args.limit || ApiLimits.COMMENTS_DEFAULT, 81 | }) 82 | comments = response.results 83 | hasMore = response.nextCursor !== null 84 | nextCursor = response.nextCursor 85 | } else { 86 | // This should never happen due to validation, but TypeScript needs it 87 | throw new Error('Invalid state: no search parameter provided') 88 | } 89 | 90 | const textContent = generateTextContent({ 91 | comments, 92 | searchType: args.commentId ? 'single' : args.taskId ? 'task' : 'project', 93 | searchId: args.commentId || args.taskId || args.projectId || '', 94 | hasMore, 95 | nextCursor, 96 | }) 97 | 98 | return getToolOutput({ 99 | textContent, 100 | structuredContent: { 101 | comments, 102 | searchType: args.commentId ? 'single' : args.taskId ? 'task' : 'project', 103 | searchId: args.commentId || args.taskId || args.projectId || '', 104 | hasMore, 105 | nextCursor, 106 | totalCount: comments.length, 107 | }, 108 | }) 109 | }, 110 | } satisfies TodoistTool 111 | 112 | function generateTextContent({ 113 | comments, 114 | searchType, 115 | searchId, 116 | hasMore, 117 | nextCursor, 118 | }: { 119 | comments: Comment[] 120 | searchType: 'single' | 'task' | 'project' 121 | searchId: string 122 | hasMore: boolean 123 | nextCursor: string | null 124 | }): string { 125 | if (comments.length === 0) { 126 | return `No comments found for ${searchType}${searchType !== 'single' ? ` ${searchId}` : ''}` 127 | } 128 | 129 | // Build summary 130 | let summary: string 131 | if (searchType === 'single') { 132 | const comment = comments[0] 133 | if (!comment) { 134 | return 'Comment not found' 135 | } 136 | const hasAttachment = comment.fileAttachment !== null 137 | const attachmentInfo = hasAttachment 138 | ? ` • Has attachment: ${comment.fileAttachment?.fileName || 'file'}` 139 | : '' 140 | summary = `Found comment${attachmentInfo} • id=${comment.id}` 141 | } else { 142 | const attachmentCount = comments.filter((c) => c.fileAttachment !== null).length 143 | const attachmentInfo = attachmentCount > 0 ? ` (${attachmentCount} with attachments)` : '' 144 | const commentsLabel = comments.length === 1 ? 'comment' : 'comments' 145 | summary = `Found ${comments.length} ${commentsLabel} for ${searchType} ${searchId}${attachmentInfo}` 146 | 147 | if (hasMore) { 148 | summary += ' • More available' 149 | } 150 | } 151 | 152 | // Only show pagination next step if there's a cursor 153 | if (nextCursor) { 154 | const next = formatNextSteps([], nextCursor) 155 | return `${summary}\n${next}` 156 | } 157 | 158 | return summary 159 | } 160 | 161 | export { findComments } 162 | -------------------------------------------------------------------------------- /src/tools/find-completed-tasks.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { appendToQuery, resolveResponsibleUser } from '../filter-helpers.js' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { mapTask } from '../tool-helpers.js' 6 | import { ApiLimits } from '../utils/constants.js' 7 | import { generateLabelsFilter, LabelsSchema } from '../utils/labels.js' 8 | import { TaskSchema as TaskOutputSchema } from '../utils/output-schemas.js' 9 | import { previewTasks, summarizeList } from '../utils/response-builders.js' 10 | import { ToolNames } from '../utils/tool-names.js' 11 | 12 | // No ToolNames constants needed - we only use cursor-based pagination 13 | 14 | const ArgsSchema = { 15 | getBy: z 16 | .enum(['completion', 'due']) 17 | .default('completion') 18 | .describe( 19 | 'The method to use to get the tasks: "completion" to get tasks by completion date (ie, when the task was actually completed), "due" to get tasks by due date (ie, when the task was due to be completed by).', 20 | ), 21 | since: z 22 | .string() 23 | .date() 24 | .regex(/^\d{4}-\d{2}-\d{2}$/) 25 | .describe('The start date to get the tasks for. Format: YYYY-MM-DD.'), 26 | until: z 27 | .string() 28 | .date() 29 | .regex(/^\d{4}-\d{2}-\d{2}$/) 30 | .describe('The start date to get the tasks for. Format: YYYY-MM-DD.'), 31 | workspaceId: z.string().optional().describe('The ID of the workspace to get the tasks for.'), 32 | projectId: z 33 | .string() 34 | .optional() 35 | .describe( 36 | 'The ID of the project to get the tasks for. Project ID should be an ID string, or the text "inbox", for inbox tasks.', 37 | ), 38 | sectionId: z.string().optional().describe('The ID of the section to get the tasks for.'), 39 | parentId: z.string().optional().describe('The ID of the parent task to get the tasks for.'), 40 | responsibleUser: z 41 | .string() 42 | .optional() 43 | .describe( 44 | 'Find tasks assigned to this user. Can be a user ID, name, or email address. Defaults to all collaborators when omitted.', 45 | ), 46 | 47 | limit: z 48 | .number() 49 | .int() 50 | .min(1) 51 | .max(ApiLimits.COMPLETED_TASKS_MAX) 52 | .default(ApiLimits.COMPLETED_TASKS_DEFAULT) 53 | .describe('The maximum number of tasks to return.'), 54 | cursor: z 55 | .string() 56 | .optional() 57 | .describe( 58 | 'The cursor to get the next page of tasks (cursor is obtained from the previous call to this tool, with the same parameters).', 59 | ), 60 | ...LabelsSchema, 61 | } 62 | 63 | const OutputSchema = { 64 | tasks: z.array(TaskOutputSchema).describe('The found completed tasks.'), 65 | nextCursor: z.string().optional().describe('Cursor for the next page of results.'), 66 | totalCount: z.number().describe('The total number of tasks in this page.'), 67 | hasMore: z.boolean().describe('Whether there are more results available.'), 68 | appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), 69 | } 70 | 71 | const findCompletedTasks = { 72 | name: ToolNames.FIND_COMPLETED_TASKS, 73 | description: 74 | 'Get completed tasks (includes all collaborators by default—use responsibleUser to narrow).', 75 | parameters: ArgsSchema, 76 | outputSchema: OutputSchema, 77 | async execute(args, client) { 78 | const { getBy, labels, labelsOperator, since, until, responsibleUser, projectId, ...rest } = 79 | args 80 | 81 | // Resolve assignee name to user ID if provided 82 | const resolved = await resolveResponsibleUser(client, responsibleUser) 83 | const assigneeEmail = resolved?.email 84 | 85 | // Build combined filter query (labels + assignment) 86 | const labelsFilter = generateLabelsFilter(labels, labelsOperator) 87 | let filterQuery = labelsFilter 88 | 89 | if (resolved && assigneeEmail) { 90 | filterQuery = appendToQuery(filterQuery, `assigned to: ${assigneeEmail}`) 91 | } 92 | 93 | // Get user timezone to convert local dates to UTC 94 | const user = await client.getUser() 95 | const userGmtOffset = user.tzInfo?.gmtString || '+00:00' 96 | 97 | // Resolve "inbox" to actual inbox project ID if needed 98 | const resolvedProjectId = projectId === 'inbox' ? user.inboxProjectId : projectId 99 | 100 | // Convert user's local date to UTC timestamps 101 | // This ensures we capture the entire day from the user's perspective 102 | const sinceWithOffset = `${since}T00:00:00${userGmtOffset}` 103 | const untilWithOffset = `${until}T23:59:59${userGmtOffset}` 104 | 105 | // Parse and convert to UTC 106 | const sinceDateTime = new Date(sinceWithOffset).toISOString() 107 | const untilDateTime = new Date(untilWithOffset).toISOString() 108 | 109 | const { items, nextCursor } = 110 | getBy === 'completion' 111 | ? await client.getCompletedTasksByCompletionDate({ 112 | ...rest, 113 | projectId: resolvedProjectId, 114 | since: sinceDateTime, 115 | until: untilDateTime, 116 | ...(filterQuery ? { filterQuery, filterLang: 'en' } : {}), 117 | }) 118 | : await client.getCompletedTasksByDueDate({ 119 | ...rest, 120 | projectId: resolvedProjectId, 121 | since: sinceDateTime, 122 | until: untilDateTime, 123 | ...(filterQuery ? { filterQuery, filterLang: 'en' } : {}), 124 | }) 125 | const mappedTasks = items.map(mapTask) 126 | 127 | const textContent = generateTextContent({ 128 | tasks: mappedTasks, 129 | args, 130 | nextCursor, 131 | assigneeEmail, 132 | }) 133 | 134 | return getToolOutput({ 135 | textContent, 136 | structuredContent: { 137 | tasks: mappedTasks, 138 | nextCursor, 139 | totalCount: mappedTasks.length, 140 | hasMore: Boolean(nextCursor), 141 | appliedFilters: args, 142 | }, 143 | }) 144 | }, 145 | } satisfies TodoistTool 146 | 147 | function generateTextContent({ 148 | tasks, 149 | args, 150 | nextCursor, 151 | assigneeEmail, 152 | }: { 153 | tasks: ReturnType[] 154 | args: z.infer> 155 | nextCursor: string | null 156 | assigneeEmail?: string 157 | }) { 158 | // Generate subject description 159 | const getByText = args.getBy === 'completion' ? 'completed' : 'due' 160 | const subject = `Completed tasks (by ${getByText} date)` 161 | 162 | // Generate filter hints 163 | const filterHints: string[] = [] 164 | filterHints.push(`${getByText} date: ${args.since} to ${args.until}`) 165 | if (args.projectId) filterHints.push(`project: ${args.projectId}`) 166 | if (args.sectionId) filterHints.push(`section: ${args.sectionId}`) 167 | if (args.parentId) filterHints.push(`parent: ${args.parentId}`) 168 | if (args.workspaceId) filterHints.push(`workspace: ${args.workspaceId}`) 169 | 170 | // Add label filter information 171 | if (args.labels && args.labels.length > 0) { 172 | const labelText = args.labels 173 | .map((label) => `@${label}`) 174 | .join(args.labelsOperator === 'and' ? ' & ' : ' | ') 175 | filterHints.push(`labels: ${labelText}`) 176 | } 177 | 178 | // Add responsible user filter information 179 | if (args.responsibleUser) { 180 | const email = assigneeEmail || args.responsibleUser 181 | filterHints.push(`assigned to: ${email}`) 182 | } 183 | 184 | // Generate helpful suggestions for empty results 185 | const zeroReasonHints: string[] = [] 186 | if (tasks.length === 0) { 187 | zeroReasonHints.push('No tasks completed in this date range') 188 | zeroReasonHints.push('Try expanding the date range') 189 | if (args.projectId || args.sectionId || args.parentId) { 190 | zeroReasonHints.push('Try removing project/section/parent filters') 191 | } 192 | if (args.getBy === 'due') { 193 | zeroReasonHints.push('Try switching to "completion" date instead') 194 | } 195 | } 196 | 197 | return summarizeList({ 198 | subject, 199 | count: tasks.length, 200 | limit: args.limit, 201 | nextCursor: nextCursor ?? undefined, 202 | filterHints, 203 | previewLines: previewTasks(tasks, Math.min(tasks.length, args.limit)), 204 | zeroReasonHints, 205 | }) 206 | } 207 | 208 | export { findCompletedTasks } 209 | -------------------------------------------------------------------------------- /src/tools/find-project-collaborators.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { getToolOutput } from '../mcp-helpers.js' 3 | import type { TodoistTool } from '../todoist-tool.js' 4 | import { type Project } from '../tool-helpers.js' 5 | import { CollaboratorSchema } from '../utils/output-schemas.js' 6 | import { summarizeList } from '../utils/response-builders.js' 7 | import { ToolNames } from '../utils/tool-names.js' 8 | import { type ProjectCollaborator, userResolver } from '../utils/user-resolver.js' 9 | 10 | const { FIND_PROJECTS, ADD_TASKS, UPDATE_TASKS } = ToolNames 11 | 12 | const ArgsSchema = { 13 | projectId: z.string().min(1).describe('The ID of the project to search for collaborators in.'), 14 | searchTerm: z 15 | .string() 16 | .optional() 17 | .describe( 18 | 'Search for a collaborator by name or email (partial and case insensitive match). If omitted, all collaborators in the project are returned.', 19 | ), 20 | } 21 | 22 | const OutputSchema = { 23 | collaborators: z.array(CollaboratorSchema).describe('The found collaborators.'), 24 | projectInfo: z 25 | .object({ 26 | id: z.string().describe('The project ID.'), 27 | name: z.string().describe('The project name.'), 28 | isShared: z.boolean().describe('Whether the project is shared.'), 29 | }) 30 | .optional() 31 | .describe('Information about the project.'), 32 | totalCount: z.number().describe('The total number of collaborators found.'), 33 | totalAvailable: z 34 | .number() 35 | .optional() 36 | .describe('The total number of available collaborators in the project.'), 37 | appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), 38 | } 39 | 40 | const findProjectCollaborators = { 41 | name: ToolNames.FIND_PROJECT_COLLABORATORS, 42 | description: 'Search for collaborators by name or other criteria in a project.', 43 | parameters: ArgsSchema, 44 | outputSchema: OutputSchema, 45 | async execute(args, client) { 46 | const { projectId, searchTerm } = args 47 | 48 | // First, validate that the project exists and get basic info 49 | let projectName = projectId 50 | let project: Project 51 | try { 52 | project = await client.getProject(projectId) 53 | if (!project) { 54 | throw new Error(`Project with ID "${projectId}" not found or not accessible`) 55 | } 56 | projectName = project.name 57 | 58 | if (!project.isShared) { 59 | const textContent = `Project "${projectName}" is not shared and has no collaborators.\n\n**Next steps:**\n• Share the project to enable collaboration\n• Use ${ADD_TASKS} and ${UPDATE_TASKS} for assignment features once shared` 60 | 61 | return getToolOutput({ 62 | textContent, 63 | structuredContent: { 64 | collaborators: [], 65 | projectInfo: { 66 | id: projectId, 67 | name: projectName, 68 | isShared: false, 69 | }, 70 | totalCount: 0, 71 | appliedFilters: args, 72 | }, 73 | }) 74 | } 75 | } catch (error) { 76 | throw new Error( 77 | `Failed to access project "${projectId}": ${error instanceof Error ? error.message : 'Unknown error'}`, 78 | ) 79 | } 80 | 81 | // Get collaborators for the project 82 | const allCollaborators = await userResolver.getProjectCollaborators(client, projectId) 83 | 84 | if (allCollaborators.length === 0) { 85 | const textContent = `Project "${projectName}" has no collaborators or collaborator data is not accessible.\n\n**Next steps:**\n• Check project sharing settings\n• Ensure you have permission to view collaborators\n• Try refreshing or re-sharing the project` 86 | 87 | return getToolOutput({ 88 | textContent, 89 | structuredContent: { 90 | collaborators: [], 91 | projectInfo: { 92 | id: projectId, 93 | name: projectName, 94 | isShared: true, 95 | }, 96 | totalCount: 0, 97 | appliedFilters: args, 98 | }, 99 | }) 100 | } 101 | 102 | // Filter collaborators if search term provided 103 | let filteredCollaborators = allCollaborators 104 | if (searchTerm) { 105 | const searchLower = searchTerm.toLowerCase().trim() 106 | filteredCollaborators = allCollaborators.filter( 107 | (collaborator) => 108 | collaborator.name.toLowerCase().includes(searchLower) || 109 | collaborator.email.toLowerCase().includes(searchLower), 110 | ) 111 | } 112 | 113 | const textContent = generateTextContent({ 114 | collaborators: filteredCollaborators, 115 | projectName, 116 | searchTerm, 117 | totalAvailable: allCollaborators.length, 118 | }) 119 | 120 | return getToolOutput({ 121 | textContent, 122 | structuredContent: { 123 | collaborators: filteredCollaborators, 124 | projectInfo: { 125 | id: projectId, 126 | name: projectName, 127 | isShared: true, 128 | }, 129 | totalCount: filteredCollaborators.length, 130 | totalAvailable: allCollaborators.length, 131 | appliedFilters: args, 132 | }, 133 | }) 134 | }, 135 | } satisfies TodoistTool 136 | 137 | function generateTextContent({ 138 | collaborators, 139 | projectName, 140 | searchTerm, 141 | totalAvailable, 142 | }: { 143 | collaborators: ProjectCollaborator[] 144 | projectName: string 145 | searchTerm?: string 146 | totalAvailable: number 147 | }) { 148 | const subject = searchTerm 149 | ? `Project collaborators matching "${searchTerm}"` 150 | : 'Project collaborators' 151 | 152 | const filterHints: string[] = [] 153 | if (searchTerm) { 154 | filterHints.push(`matching "${searchTerm}"`) 155 | } 156 | filterHints.push(`in project "${projectName}"`) 157 | 158 | let previewLines: string[] = [] 159 | if (collaborators.length > 0) { 160 | previewLines = collaborators.slice(0, 10).map((collaborator) => { 161 | const displayName = collaborator.name || 'Unknown Name' 162 | const email = collaborator.email || 'No email' 163 | return `• ${displayName} (${email}) - ID: ${collaborator.id}` 164 | }) 165 | 166 | if (collaborators.length > 10) { 167 | previewLines.push(`... and ${collaborators.length - 10} more`) 168 | } 169 | } 170 | 171 | const zeroReasonHints: string[] = [] 172 | if (collaborators.length === 0) { 173 | if (searchTerm) { 174 | zeroReasonHints.push(`No collaborators match "${searchTerm}"`) 175 | zeroReasonHints.push('Try a broader search term or check spelling') 176 | if (totalAvailable > 0) { 177 | zeroReasonHints.push(`${totalAvailable} collaborators available without filter`) 178 | } 179 | } else { 180 | zeroReasonHints.push('Project has no collaborators') 181 | zeroReasonHints.push('Share the project to add collaborators') 182 | } 183 | } 184 | 185 | const nextSteps: string[] = [] 186 | if (collaborators.length > 0) { 187 | nextSteps.push(`Use ${ADD_TASKS} with responsibleUser to assign new tasks`) 188 | nextSteps.push(`Use ${UPDATE_TASKS} with responsibleUser to reassign existing tasks`) 189 | nextSteps.push('Use collaborator names, emails, or IDs for assignments') 190 | } else { 191 | nextSteps.push(`Use ${FIND_PROJECTS} to find other projects`) 192 | if (searchTerm && totalAvailable > 0) { 193 | nextSteps.push('Try searching without filters to see all collaborators') 194 | } 195 | } 196 | 197 | return summarizeList({ 198 | subject, 199 | count: collaborators.length, 200 | filterHints, 201 | previewLines: previewLines.join('\n'), 202 | zeroReasonHints, 203 | nextSteps, 204 | }) 205 | } 206 | 207 | export { findProjectCollaborators } 208 | -------------------------------------------------------------------------------- /src/tools/find-projects.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { getToolOutput } from '../mcp-helpers.js' 3 | import type { TodoistTool } from '../todoist-tool.js' 4 | import { mapProject } from '../tool-helpers.js' 5 | import { ApiLimits } from '../utils/constants.js' 6 | import { ProjectSchema as ProjectOutputSchema } from '../utils/output-schemas.js' 7 | import { formatProjectPreview, summarizeList } from '../utils/response-builders.js' 8 | import { ToolNames } from '../utils/tool-names.js' 9 | 10 | const { ADD_PROJECTS } = ToolNames 11 | 12 | const ArgsSchema = { 13 | search: z 14 | .string() 15 | .optional() 16 | .describe( 17 | 'Search for a project by name (partial and case insensitive match). If omitted, all projects are returned.', 18 | ), 19 | limit: z 20 | .number() 21 | .int() 22 | .min(1) 23 | .max(ApiLimits.PROJECTS_MAX) 24 | .default(ApiLimits.PROJECTS_DEFAULT) 25 | .describe('The maximum number of projects to return.'), 26 | cursor: z 27 | .string() 28 | .optional() 29 | .describe( 30 | 'The cursor to get the next page of projects (cursor is obtained from the previous call to this tool, with the same parameters).', 31 | ), 32 | } 33 | 34 | const OutputSchema = { 35 | projects: z.array(ProjectOutputSchema).describe('The found projects.'), 36 | nextCursor: z.string().optional().describe('Cursor for the next page of results.'), 37 | totalCount: z.number().describe('The total number of projects in this page.'), 38 | hasMore: z.boolean().describe('Whether there are more results available.'), 39 | appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), 40 | } 41 | 42 | const findProjects = { 43 | name: ToolNames.FIND_PROJECTS, 44 | description: 45 | 'List all projects or search for projects by name. If search parameter is omitted, all projects are returned.', 46 | parameters: ArgsSchema, 47 | outputSchema: OutputSchema, 48 | async execute(args, client) { 49 | const { results, nextCursor } = await client.getProjects({ 50 | limit: args.limit, 51 | cursor: args.cursor ?? null, 52 | }) 53 | const searchLower = args.search ? args.search.toLowerCase() : undefined 54 | const filtered = searchLower 55 | ? results.filter((project) => project.name.toLowerCase().includes(searchLower)) 56 | : results 57 | const projects = filtered.map(mapProject) 58 | 59 | return getToolOutput({ 60 | textContent: generateTextContent({ 61 | projects, 62 | args, 63 | nextCursor, 64 | }), 65 | structuredContent: { 66 | projects, 67 | nextCursor, 68 | totalCount: projects.length, 69 | hasMore: Boolean(nextCursor), 70 | appliedFilters: args, 71 | }, 72 | }) 73 | }, 74 | } satisfies TodoistTool 75 | 76 | function generateTextContent({ 77 | projects, 78 | args, 79 | nextCursor, 80 | }: { 81 | projects: ReturnType[] 82 | args: z.infer> 83 | nextCursor: string | null 84 | }) { 85 | // Generate subject description 86 | const subject = args.search ? `Projects matching "${args.search}"` : 'Projects' 87 | 88 | // Generate filter hints 89 | const filterHints: string[] = [] 90 | if (args.search) { 91 | filterHints.push(`search: "${args.search}"`) 92 | } 93 | 94 | // Generate project preview lines 95 | const previewLimit = 10 96 | const previewProjects = projects.slice(0, previewLimit) 97 | const previewLines = previewProjects.map(formatProjectPreview).join('\n') 98 | const remainingCount = projects.length - previewLimit 99 | const previewWithMore = 100 | remainingCount > 0 ? `${previewLines}\n …and ${remainingCount} more` : previewLines 101 | 102 | // Generate helpful suggestions for empty results 103 | const zeroReasonHints: string[] = [] 104 | if (projects.length === 0) { 105 | if (args.search) { 106 | zeroReasonHints.push('Try broader search terms') 107 | zeroReasonHints.push('Check spelling') 108 | zeroReasonHints.push('Remove search to see all projects') 109 | } else { 110 | zeroReasonHints.push('No projects created yet') 111 | zeroReasonHints.push(`Use ${ADD_PROJECTS} to create a project`) 112 | } 113 | } 114 | 115 | return summarizeList({ 116 | subject, 117 | count: projects.length, 118 | limit: args.limit, 119 | nextCursor: nextCursor ?? undefined, 120 | filterHints, 121 | previewLines: previewWithMore, 122 | zeroReasonHints, 123 | }) 124 | } 125 | 126 | export { findProjects } 127 | -------------------------------------------------------------------------------- /src/tools/find-sections.ts: -------------------------------------------------------------------------------- 1 | import type { Section } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { SectionSchema as SectionOutputSchema } from '../utils/output-schemas.js' 6 | import { summarizeList } from '../utils/response-builders.js' 7 | import { ToolNames } from '../utils/tool-names.js' 8 | 9 | const { ADD_SECTIONS } = ToolNames 10 | 11 | const ArgsSchema = { 12 | projectId: z 13 | .string() 14 | .min(1) 15 | .describe( 16 | 'The ID of the project to search sections in. Project ID should be an ID string, or the text "inbox", for inbox tasks.', 17 | ), 18 | search: z 19 | .string() 20 | .optional() 21 | .describe( 22 | 'Search for a section by name (partial and case insensitive match). If omitted, all sections in the project are returned.', 23 | ), 24 | } 25 | 26 | type SectionSummary = { 27 | id: string 28 | name: string 29 | } 30 | 31 | const OutputSchema = { 32 | sections: z.array(SectionOutputSchema).describe('The found sections.'), 33 | totalCount: z.number().describe('The total number of sections found.'), 34 | appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), 35 | } 36 | 37 | const findSections = { 38 | name: ToolNames.FIND_SECTIONS, 39 | description: 'Search for sections by name or other criteria in a project.', 40 | parameters: ArgsSchema, 41 | outputSchema: OutputSchema, 42 | async execute(args, client) { 43 | // Resolve "inbox" to actual inbox project ID if needed 44 | const resolvedProjectId = 45 | args.projectId === 'inbox' ? (await client.getUser()).inboxProjectId : args.projectId 46 | 47 | const { results } = await client.getSections({ 48 | projectId: resolvedProjectId, 49 | }) 50 | const searchLower = args.search ? args.search.toLowerCase() : undefined 51 | const filtered = searchLower 52 | ? results.filter((section: Section) => section.name.toLowerCase().includes(searchLower)) 53 | : results 54 | 55 | const sections = filtered.map((section) => ({ 56 | id: section.id, 57 | name: section.name, 58 | })) 59 | 60 | const textContent = generateTextContent({ 61 | sections, 62 | projectId: args.projectId, 63 | search: args.search, 64 | }) 65 | 66 | return getToolOutput({ 67 | textContent, 68 | structuredContent: { 69 | sections, 70 | totalCount: sections.length, 71 | appliedFilters: args, 72 | }, 73 | }) 74 | }, 75 | } satisfies TodoistTool 76 | 77 | function generateTextContent({ 78 | sections, 79 | projectId, 80 | search, 81 | }: { 82 | sections: SectionSummary[] 83 | projectId: string 84 | search?: string 85 | }): string { 86 | const zeroReasonHints: string[] = [] 87 | 88 | if (search) { 89 | zeroReasonHints.push('Try broader search terms') 90 | zeroReasonHints.push('Check spelling') 91 | zeroReasonHints.push('Remove search to see all sections') 92 | } else { 93 | zeroReasonHints.push('Project has no sections yet') 94 | zeroReasonHints.push(`Use ${ADD_SECTIONS} to create sections`) 95 | } 96 | 97 | // Data-driven next steps based on results 98 | const subject = search 99 | ? `Sections in project ${projectId} matching "${search}"` 100 | : `Sections in project ${projectId}` 101 | 102 | const previewLines = 103 | sections.length > 0 104 | ? sections.map((section) => ` ${section.name} • id=${section.id}`).join('\n') 105 | : undefined 106 | 107 | return summarizeList({ 108 | subject, 109 | count: sections.length, 110 | previewLines, 111 | zeroReasonHints, 112 | }) 113 | } 114 | 115 | export { findSections } 116 | -------------------------------------------------------------------------------- /src/tools/search.ts: -------------------------------------------------------------------------------- 1 | import { getProjectUrl, getTaskUrl } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getErrorOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { getTasksByFilter } from '../tool-helpers.js' 6 | import { ApiLimits } from '../utils/constants.js' 7 | import { ToolNames } from '../utils/tool-names.js' 8 | 9 | const ArgsSchema = { 10 | query: z.string().min(1).describe('The search query string to find tasks and projects.'), 11 | } 12 | 13 | type SearchResult = { 14 | id: string 15 | title: string 16 | url: string 17 | } 18 | 19 | type SearchToolOutput = { 20 | content: { type: 'text'; text: string }[] 21 | isError?: boolean 22 | } 23 | 24 | const OutputSchema = { 25 | results: z 26 | .array( 27 | z.object({ 28 | id: z.string().describe('The ID of the result.'), 29 | title: z.string().describe('The title of the result.'), 30 | url: z.string().describe('The URL of the result.'), 31 | }), 32 | ) 33 | .describe('The search results.'), 34 | totalCount: z.number().describe('Total number of results found.'), 35 | } 36 | 37 | /** 38 | * OpenAI MCP search tool - returns a list of relevant search results from Todoist. 39 | * 40 | * This tool follows the OpenAI MCP search tool specification: 41 | * @see https://platform.openai.com/docs/mcp#search-tool 42 | */ 43 | const search = { 44 | name: ToolNames.SEARCH, 45 | description: 46 | 'Search across tasks and projects in Todoist. Returns a list of relevant results with IDs, titles, and URLs.', 47 | parameters: ArgsSchema, 48 | outputSchema: OutputSchema, 49 | async execute(args, client): Promise { 50 | try { 51 | const { query } = args 52 | 53 | // Search both tasks and projects in parallel 54 | // Use TASKS_MAX for search since this tool doesn't support pagination 55 | const [tasksResult, projectsResponse] = await Promise.all([ 56 | getTasksByFilter({ 57 | client, 58 | query: `search: ${query}`, 59 | limit: ApiLimits.TASKS_MAX, 60 | cursor: undefined, 61 | }), 62 | client.getProjects({ limit: ApiLimits.PROJECTS_MAX }), 63 | ]) 64 | 65 | // Filter projects by search query (case-insensitive) 66 | const searchLower = query.toLowerCase() 67 | const matchingProjects = projectsResponse.results.filter((project) => 68 | project.name.toLowerCase().includes(searchLower), 69 | ) 70 | 71 | // Build results array 72 | const results: SearchResult[] = [] 73 | 74 | // Add task results with composite IDs 75 | for (const task of tasksResult.tasks) { 76 | results.push({ 77 | id: `task:${task.id}`, 78 | title: task.content, 79 | url: getTaskUrl(task.id), 80 | }) 81 | } 82 | 83 | // Add project results with composite IDs 84 | for (const project of matchingProjects) { 85 | results.push({ 86 | id: `project:${project.id}`, 87 | title: project.name, 88 | url: getProjectUrl(project.id), 89 | }) 90 | } 91 | 92 | // Return as JSON-encoded string in a text content item (OpenAI MCP spec) 93 | const jsonText = JSON.stringify({ results }) 94 | return { content: [{ type: 'text' as const, text: jsonText }] } 95 | } catch (error) { 96 | const message = error instanceof Error ? error.message : 'An unknown error occurred' 97 | return getErrorOutput(message) 98 | } 99 | }, 100 | } satisfies TodoistTool 101 | 102 | export { search } 103 | -------------------------------------------------------------------------------- /src/tools/update-comments.ts: -------------------------------------------------------------------------------- 1 | import type { Comment } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { CommentSchema as CommentOutputSchema } from '../utils/output-schemas.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const CommentUpdateSchema = z.object({ 9 | id: z.string().min(1).describe('The ID of the comment to update.'), 10 | content: z.string().min(1).describe('The new content for the comment.'), 11 | }) 12 | 13 | const ArgsSchema = { 14 | comments: z.array(CommentUpdateSchema).min(1).describe('The comments to update.'), 15 | } 16 | 17 | const OutputSchema = { 18 | comments: z.array(CommentOutputSchema).describe('The updated comments.'), 19 | totalCount: z.number().describe('The total number of comments updated.'), 20 | updatedCommentIds: z.array(z.string()).describe('The IDs of the updated comments.'), 21 | appliedOperations: z 22 | .object({ 23 | updateCount: z.number().describe('The number of comments updated.'), 24 | }) 25 | .describe('Summary of operations performed.'), 26 | } 27 | 28 | const updateComments = { 29 | name: ToolNames.UPDATE_COMMENTS, 30 | description: 'Update multiple existing comments with new content.', 31 | parameters: ArgsSchema, 32 | outputSchema: OutputSchema, 33 | async execute(args, client) { 34 | const { comments } = args 35 | 36 | const updateCommentPromises = comments.map(async (comment) => { 37 | return await client.updateComment(comment.id, { content: comment.content }) 38 | }) 39 | 40 | const updatedComments = await Promise.all(updateCommentPromises) 41 | 42 | const textContent = generateTextContent({ 43 | comments: updatedComments, 44 | }) 45 | 46 | return getToolOutput({ 47 | textContent, 48 | structuredContent: { 49 | comments: updatedComments, 50 | totalCount: updatedComments.length, 51 | updatedCommentIds: updatedComments.map((comment) => comment.id), 52 | appliedOperations: { 53 | updateCount: updatedComments.length, 54 | }, 55 | }, 56 | }) 57 | }, 58 | } satisfies TodoistTool 59 | 60 | function generateTextContent({ comments }: { comments: Comment[] }): string { 61 | // Group comments by entity type and count 62 | const taskComments = comments.filter((c) => c.taskId).length 63 | const projectComments = comments.filter((c) => c.projectId).length 64 | 65 | const parts: string[] = [] 66 | if (taskComments > 0) { 67 | const commentsLabel = taskComments > 1 ? 'comments' : 'comment' 68 | parts.push(`${taskComments} task ${commentsLabel}`) 69 | } 70 | if (projectComments > 0) { 71 | const commentsLabel = projectComments > 1 ? 'comments' : 'comment' 72 | parts.push(`${projectComments} project ${commentsLabel}`) 73 | } 74 | const summary = parts.length > 0 ? `Updated ${parts.join(' and ')}` : 'No comments updated' 75 | 76 | return summary 77 | } 78 | 79 | export { updateComments } 80 | -------------------------------------------------------------------------------- /src/tools/update-projects.ts: -------------------------------------------------------------------------------- 1 | import type { PersonalProject, WorkspaceProject } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { ProjectSchema as ProjectOutputSchema } from '../utils/output-schemas.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const ProjectUpdateSchema = z.object({ 9 | id: z.string().min(1).describe('The ID of the project to update.'), 10 | name: z.string().min(1).optional().describe('The new name of the project.'), 11 | isFavorite: z.boolean().optional().describe('Whether the project is a favorite.'), 12 | viewStyle: z.enum(['list', 'board', 'calendar']).optional().describe('The project view style.'), 13 | }) 14 | 15 | type ProjectUpdate = z.infer 16 | 17 | const ArgsSchema = { 18 | projects: z.array(ProjectUpdateSchema).min(1).describe('The projects to update.'), 19 | } 20 | 21 | const OutputSchema = { 22 | projects: z.array(ProjectOutputSchema).describe('The updated projects.'), 23 | totalCount: z.number().describe('The total number of projects updated.'), 24 | updatedProjectIds: z.array(z.string()).describe('The IDs of the updated projects.'), 25 | appliedOperations: z 26 | .object({ 27 | updateCount: z.number().describe('The number of projects actually updated.'), 28 | skippedCount: z.number().describe('The number of projects skipped (no changes).'), 29 | }) 30 | .describe('Summary of operations performed.'), 31 | } 32 | 33 | const updateProjects = { 34 | name: ToolNames.UPDATE_PROJECTS, 35 | description: 'Update multiple existing projects with new values.', 36 | parameters: ArgsSchema, 37 | outputSchema: OutputSchema, 38 | async execute(args, client) { 39 | const { projects } = args 40 | const updateProjectsPromises = projects.map(async (project) => { 41 | if (!hasUpdatesToMake(project)) { 42 | return undefined 43 | } 44 | 45 | const { id, ...updateArgs } = project 46 | return await client.updateProject(id, updateArgs) 47 | }) 48 | 49 | const updatedProjects = (await Promise.all(updateProjectsPromises)).filter( 50 | (project): project is PersonalProject | WorkspaceProject => project !== undefined, 51 | ) 52 | 53 | const textContent = generateTextContent({ 54 | projects: updatedProjects, 55 | args, 56 | }) 57 | 58 | return getToolOutput({ 59 | textContent, 60 | structuredContent: { 61 | projects: updatedProjects, 62 | totalCount: updatedProjects.length, 63 | updatedProjectIds: updatedProjects.map((project) => project.id), 64 | appliedOperations: { 65 | updateCount: updatedProjects.length, 66 | skippedCount: projects.length - updatedProjects.length, 67 | }, 68 | }, 69 | }) 70 | }, 71 | } satisfies TodoistTool 72 | 73 | function generateTextContent({ 74 | projects, 75 | args, 76 | }: { 77 | projects: (PersonalProject | WorkspaceProject)[] 78 | args: z.infer> 79 | }) { 80 | const totalRequested = args.projects.length 81 | const actuallyUpdated = projects.length 82 | const skipped = totalRequested - actuallyUpdated 83 | 84 | const count = projects.length 85 | const projectList = projects.map((project) => `• ${project.name} (id=${project.id})`).join('\n') 86 | 87 | let summary = `Updated ${count} project${count === 1 ? '' : 's'}` 88 | if (skipped > 0) { 89 | summary += ` (${skipped} skipped - no changes)` 90 | } 91 | 92 | if (count > 0) { 93 | summary += `:\n${projectList}` 94 | } 95 | 96 | return summary 97 | } 98 | 99 | function hasUpdatesToMake({ id, ...otherUpdateArgs }: ProjectUpdate) { 100 | return Object.keys(otherUpdateArgs).length > 0 101 | } 102 | 103 | export { updateProjects } 104 | -------------------------------------------------------------------------------- /src/tools/update-sections.ts: -------------------------------------------------------------------------------- 1 | import type { Section } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { SectionSchema as SectionOutputSchema } from '../utils/output-schemas.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const SectionUpdateSchema = z.object({ 9 | id: z.string().min(1).describe('The ID of the section to update.'), 10 | name: z.string().min(1).describe('The new name of the section.'), 11 | }) 12 | 13 | const ArgsSchema = { 14 | sections: z.array(SectionUpdateSchema).min(1).describe('The sections to update.'), 15 | } 16 | 17 | const OutputSchema = { 18 | sections: z.array(SectionOutputSchema).describe('The updated sections.'), 19 | totalCount: z.number().describe('The total number of sections updated.'), 20 | updatedSectionIds: z.array(z.string()).describe('The IDs of the updated sections.'), 21 | } 22 | 23 | const updateSections = { 24 | name: ToolNames.UPDATE_SECTIONS, 25 | description: 'Update multiple existing sections with new values.', 26 | parameters: ArgsSchema, 27 | outputSchema: OutputSchema, 28 | async execute({ sections }, client) { 29 | const updatedSections = await Promise.all( 30 | sections.map((section) => client.updateSection(section.id, { name: section.name })), 31 | ) 32 | 33 | const textContent = generateTextContent({ 34 | sections: updatedSections, 35 | }) 36 | 37 | return getToolOutput({ 38 | textContent, 39 | structuredContent: { 40 | sections: updatedSections, 41 | totalCount: updatedSections.length, 42 | updatedSectionIds: updatedSections.map((section) => section.id), 43 | }, 44 | }) 45 | }, 46 | } satisfies TodoistTool 47 | 48 | function generateTextContent({ sections }: { sections: Section[] }) { 49 | const count = sections.length 50 | const sectionList = sections 51 | .map((section) => `• ${section.name} (id=${section.id}, projectId=${section.projectId})`) 52 | .join('\n') 53 | 54 | const summary = `Updated ${count} section${count === 1 ? '' : 's'}:\n${sectionList}` 55 | 56 | return summary 57 | } 58 | 59 | export { updateSections } 60 | -------------------------------------------------------------------------------- /src/tools/user-info.ts: -------------------------------------------------------------------------------- 1 | import type { TodoistApi } from '@doist/todoist-api-typescript' 2 | import { z } from 'zod' 3 | import { getToolOutput } from '../mcp-helpers.js' 4 | import type { TodoistTool } from '../todoist-tool.js' 5 | import { ToolNames } from '../utils/tool-names.js' 6 | 7 | const ArgsSchema = {} 8 | 9 | const OutputSchema = { 10 | type: z.literal('user_info').describe('The type of the response.'), 11 | userId: z.string().describe('The user ID.'), 12 | fullName: z.string().describe('The full name of the user.'), 13 | timezone: z.string().describe('The timezone of the user.'), 14 | currentLocalTime: z.string().describe('The current local time of the user.'), 15 | startDay: z.number().describe('The start day of the week (1 = Monday, 7 = Sunday).'), 16 | startDayName: z.string().describe('The name of the start day.'), 17 | weekStartDate: z.string().describe('The start date of the current week (YYYY-MM-DD).'), 18 | weekEndDate: z.string().describe('The end date of the current week (YYYY-MM-DD).'), 19 | currentWeekNumber: z.number().describe('The current week number of the year.'), 20 | completedToday: z.number().describe('The number of tasks completed today.'), 21 | dailyGoal: z.number().describe('The daily goal for task completions.'), 22 | weeklyGoal: z.number().describe('The weekly goal for task completions.'), 23 | email: z.string().describe('The email address of the user.'), 24 | plan: z.enum(['Todoist Free', 'Todoist Pro', 'Todoist Business']).describe('The user plan.'), 25 | } 26 | 27 | type UserPlan = 'Todoist Free' | 'Todoist Pro' | 'Todoist Business' 28 | 29 | type UserInfoStructured = Record & { 30 | type: 'user_info' 31 | userId: string 32 | fullName: string 33 | timezone: string 34 | currentLocalTime: string 35 | startDay: number 36 | startDayName: string 37 | weekStartDate: string 38 | weekEndDate: string 39 | currentWeekNumber: number 40 | completedToday: number 41 | dailyGoal: number 42 | weeklyGoal: number 43 | email: string 44 | plan: UserPlan 45 | } 46 | 47 | function getUserPlan(user: { isPremium: boolean; businessAccountId?: string | null }): UserPlan { 48 | if (user.businessAccountId) { 49 | return 'Todoist Business' 50 | } 51 | if (user.isPremium) { 52 | return 'Todoist Pro' 53 | } 54 | return 'Todoist Free' 55 | } 56 | 57 | // Helper functions for date and time calculations 58 | function getWeekStartDate(date: Date, startDay: number): Date { 59 | const currentDay = date.getDay() || 7 // Convert Sunday (0) to 7 for ISO format 60 | const daysFromStart = (currentDay - startDay + 7) % 7 61 | const weekStart = new Date(date) 62 | weekStart.setDate(date.getDate() - daysFromStart) 63 | return weekStart 64 | } 65 | 66 | function getWeekEndDate(weekStart: Date): Date { 67 | const weekEnd = new Date(weekStart) 68 | weekEnd.setDate(weekStart.getDate() + 6) 69 | return weekEnd 70 | } 71 | 72 | function getWeekNumber(date: Date): number { 73 | const firstDayOfYear = new Date(date.getFullYear(), 0, 1) 74 | const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000 75 | return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7) 76 | } 77 | 78 | function getDayName(dayNumber: number): string { 79 | const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] 80 | // Convert ISO day number (1=Monday, 7=Sunday) to array index (0=Sunday, 6=Saturday) 81 | const index = dayNumber === 7 ? 0 : dayNumber 82 | return days[index] ?? 'Unknown' 83 | } 84 | 85 | function formatDate(date: Date): string { 86 | return date.toISOString().split('T')[0] ?? '' 87 | } 88 | 89 | function isValidTimezone(timezone: string): boolean { 90 | try { 91 | // Test if the timezone is valid by attempting to format a date with it 92 | new Intl.DateTimeFormat('en-US', { timeZone: timezone }) 93 | return true 94 | } catch { 95 | return false 96 | } 97 | } 98 | 99 | function getSafeTimezone(timezone: string): string { 100 | return isValidTimezone(timezone) ? timezone : 'UTC' 101 | } 102 | 103 | function formatLocalTime(date: Date, timezone: string): string { 104 | const safeTimezone = getSafeTimezone(timezone) 105 | return date.toLocaleString('en-US', { 106 | timeZone: safeTimezone, 107 | year: 'numeric', 108 | month: '2-digit', 109 | day: '2-digit', 110 | hour: '2-digit', 111 | minute: '2-digit', 112 | second: '2-digit', 113 | hour12: false, 114 | }) 115 | } 116 | 117 | async function generateUserInfo( 118 | client: TodoistApi, 119 | ): Promise<{ textContent: string; structuredContent: UserInfoStructured }> { 120 | // Get user information from Todoist API 121 | const user = await client.getUser() 122 | 123 | // Parse timezone from user data and ensure it's valid 124 | const rawTimezone = user.tzInfo?.timezone ?? 'UTC' 125 | const timezone = getSafeTimezone(rawTimezone) 126 | 127 | // Get current time in user's timezone 128 | const now = new Date() 129 | const localTime = formatLocalTime(now, timezone) 130 | 131 | // Calculate week information based on user's start day 132 | const startDay = user.startDay ?? 1 // Default to Monday if not set 133 | const startDayName = getDayName(startDay) 134 | 135 | // Determine user's plan 136 | const plan = getUserPlan(user) 137 | 138 | // Create a date object in user's timezone for accurate week calculations 139 | const userDate = new Date(now.toLocaleString('en-US', { timeZone: timezone })) 140 | const weekStart = getWeekStartDate(userDate, startDay) 141 | const weekEnd = getWeekEndDate(weekStart) 142 | const weekNumber = getWeekNumber(userDate) 143 | 144 | // Generate markdown text content 145 | const lines: string[] = [ 146 | '# User Information', 147 | '', 148 | `**User ID:** ${user.id}`, 149 | `**Full Name:** ${user.fullName}`, 150 | `**Email:** ${user.email}`, 151 | `**Timezone:** ${timezone}`, 152 | `**Current Local Time:** ${localTime}`, 153 | '', 154 | '## Week Settings', 155 | `**Week Start Day:** ${startDayName} (${startDay})`, 156 | `**Current Week:** Week ${weekNumber}`, 157 | `**Week Start Date:** ${formatDate(weekStart)}`, 158 | `**Week End Date:** ${formatDate(weekEnd)}`, 159 | '', 160 | '## Daily Progress', 161 | `**Completed Today:** ${user.completedToday}`, 162 | `**Daily Goal:** ${user.dailyGoal}`, 163 | `**Weekly Goal:** ${user.weeklyGoal}`, 164 | '', 165 | '## Account Info', 166 | `**Plan:** ${plan}`, 167 | ] 168 | 169 | const textContent = lines.join('\n') 170 | 171 | // Generate structured content 172 | const structuredContent: UserInfoStructured = { 173 | type: 'user_info', 174 | userId: user.id, 175 | fullName: user.fullName, 176 | timezone: timezone, 177 | currentLocalTime: localTime, 178 | startDay: startDay, 179 | startDayName: startDayName, 180 | weekStartDate: formatDate(weekStart), 181 | weekEndDate: formatDate(weekEnd), 182 | currentWeekNumber: weekNumber, 183 | completedToday: user.completedToday, 184 | dailyGoal: user.dailyGoal, 185 | weeklyGoal: user.weeklyGoal, 186 | email: user.email, 187 | plan: plan, 188 | } 189 | 190 | return { textContent, structuredContent } 191 | } 192 | 193 | const userInfo = { 194 | name: ToolNames.USER_INFO, 195 | description: 196 | 'Get comprehensive user information including user ID, full name, email, timezone with current local time, week start day preferences, current week dates, daily/weekly goal progress, and user plan (Free/Pro/Business).', 197 | parameters: ArgsSchema, 198 | outputSchema: OutputSchema, 199 | async execute(_args, client) { 200 | const result = await generateUserInfo(client) 201 | 202 | return getToolOutput({ 203 | textContent: result.textContent, 204 | structuredContent: result.structuredContent, 205 | }) 206 | }, 207 | } satisfies TodoistTool 208 | 209 | export { userInfo, type UserInfoStructured } 210 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application-wide constants 3 | * 4 | * This module centralizes magic numbers and configuration values 5 | * to improve maintainability and provide a single source of truth. 6 | */ 7 | 8 | // API Pagination Limits 9 | export const ApiLimits = { 10 | /** Default limit for task listings */ 11 | TASKS_DEFAULT: 10, 12 | /** Maximum limit for task search and list operations */ 13 | TASKS_MAX: 100, 14 | /** Default limit for completed tasks */ 15 | COMPLETED_TASKS_DEFAULT: 50, 16 | /** Maximum limit for completed tasks */ 17 | COMPLETED_TASKS_MAX: 200, 18 | /** Default limit for project listings */ 19 | PROJECTS_DEFAULT: 50, 20 | /** Maximum limit for project listings */ 21 | PROJECTS_MAX: 100, 22 | /** Batch size for fetching all tasks in a project */ 23 | TASKS_BATCH_SIZE: 50, 24 | /** Default limit for comment listings */ 25 | COMMENTS_DEFAULT: 10, 26 | /** Maximum limit for comment search and list operations */ 27 | COMMENTS_MAX: 10, 28 | /** Default limit for activity log listings */ 29 | ACTIVITY_DEFAULT: 20, 30 | /** Maximum limit for activity log search and list operations */ 31 | ACTIVITY_MAX: 100, 32 | } as const 33 | 34 | // UI Display Limits 35 | export const DisplayLimits = { 36 | /** Maximum number of failures to show in detailed error messages */ 37 | MAX_FAILURES_SHOWN: 3, 38 | /** Threshold for suggesting batch operations */ 39 | BATCH_OPERATION_THRESHOLD: 10, 40 | } as const 41 | 42 | // Response Builder Configuration 43 | export const ResponseConfig = { 44 | /** Maximum characters per line in text responses */ 45 | MAX_LINE_LENGTH: 100, 46 | /** Indentation for nested items */ 47 | INDENT_SIZE: 2, 48 | } as const 49 | -------------------------------------------------------------------------------- /src/utils/duration-parser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Duration parser utility for converting human-readable duration strings 3 | * to minutes using a restricted, language-neutral syntax. 4 | * 5 | * Supported formats: 6 | * - "2h" (hours only) 7 | * - "90m" (minutes only) 8 | * - "2h30m" (hours + minutes) 9 | * - "1.5h" (decimal hours) 10 | * - Supports optional spaces: "2h 30m" 11 | */ 12 | 13 | type ParsedDuration = { 14 | minutes: number 15 | } 16 | 17 | export class DurationParseError extends Error { 18 | constructor(input: string, reason: string) { 19 | super(`Invalid duration format "${input}": ${reason}`) 20 | this.name = 'DurationParseError' 21 | } 22 | } 23 | 24 | /** 25 | * Parses duration string in restricted syntax to minutes. 26 | * Max duration: 1440 minutes (24 hours) 27 | * 28 | * @param durationStr - Duration string like "2h30m", "45m", "1.5h" 29 | * @returns Parsed duration in minutes 30 | * @throws DurationParseError for invalid formats 31 | */ 32 | export function parseDuration(durationStr: string): ParsedDuration { 33 | if (!durationStr || typeof durationStr !== 'string') { 34 | throw new DurationParseError(durationStr, 'Duration must be a non-empty string') 35 | } 36 | 37 | // Remove all spaces and convert to lowercase 38 | const normalized = durationStr.trim().toLowerCase().replace(/\s+/g, '') 39 | 40 | // Check for empty string after trimming 41 | if (!normalized) { 42 | throw new DurationParseError(durationStr, 'Duration must be a non-empty string') 43 | } 44 | 45 | // Validate format with strict ordering: hours must come before minutes 46 | // This regex ensures: optional hours followed by optional minutes, no duplicates 47 | const match = normalized.match(/^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?$/) 48 | if (!match || (!match[1] && !match[2])) { 49 | throw new DurationParseError(durationStr, 'Use format like "2h", "30m", "2h30m", or "1.5h"') 50 | } 51 | 52 | let totalMinutes = 0 53 | const [, hoursStr, minutesStr] = match 54 | 55 | // Parse hours if present 56 | if (hoursStr) { 57 | const hours = Number.parseFloat(hoursStr) 58 | if (Number.isNaN(hours) || hours < 0) { 59 | throw new DurationParseError(durationStr, 'Hours must be a positive number') 60 | } 61 | totalMinutes += hours * 60 62 | } 63 | 64 | // Parse minutes if present 65 | if (minutesStr) { 66 | const minutes = Number.parseFloat(minutesStr) 67 | if (Number.isNaN(minutes) || minutes < 0) { 68 | throw new DurationParseError(durationStr, 'Minutes must be a positive number') 69 | } 70 | // Don't allow decimal minutes 71 | if (minutes % 1 !== 0) { 72 | throw new DurationParseError( 73 | durationStr, 74 | 'Minutes must be a whole number (use decimal hours instead)', 75 | ) 76 | } 77 | totalMinutes += minutes 78 | } 79 | 80 | // The regex already ensures at least one unit is present 81 | 82 | // Round to nearest minute (handles decimal hours) 83 | totalMinutes = Math.round(totalMinutes) 84 | 85 | // Validate minimum duration 86 | if (totalMinutes === 0) { 87 | throw new DurationParseError(durationStr, 'Duration must be greater than 0 minutes') 88 | } 89 | 90 | // Validate maximum duration (24 hours = 1440 minutes) 91 | if (totalMinutes > 1440) { 92 | throw new DurationParseError(durationStr, 'Duration cannot exceed 24 hours (1440 minutes)') 93 | } 94 | 95 | return { minutes: totalMinutes } 96 | } 97 | 98 | /** 99 | * Formats minutes back to a human-readable duration string. 100 | * Used when returning task data to LLMs. 101 | * 102 | * @param minutes - Duration in minutes 103 | * @returns Formatted duration string like "2h30m" or "45m" 104 | */ 105 | export function formatDuration(minutes: number): string { 106 | if (minutes <= 0) return '0m' 107 | 108 | const hours = Math.floor(minutes / 60) 109 | const remainingMinutes = minutes % 60 110 | 111 | if (hours === 0) { 112 | return `${remainingMinutes}m` 113 | } 114 | 115 | if (remainingMinutes === 0) { 116 | return `${hours}h` 117 | } 118 | 119 | return `${hours}h${remainingMinutes}m` 120 | } 121 | -------------------------------------------------------------------------------- /src/utils/labels.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const LABELS_OPERATORS = ['and', 'or'] as const 4 | type LabelsOperator = (typeof LABELS_OPERATORS)[number] 5 | 6 | export const LabelsSchema = { 7 | labels: z.string().array().optional().describe('The labels to filter the tasks by'), 8 | labelsOperator: z 9 | .enum(LABELS_OPERATORS) 10 | .optional() 11 | .describe( 12 | 'The operator to use when filtering by labels. This will dictate whether a task has all labels, or some of them. Default is "or".', 13 | ), 14 | } 15 | 16 | export function generateLabelsFilter(labels: string[] = [], labelsOperator: LabelsOperator = 'or') { 17 | if (labels.length === 0) return '' 18 | const operator = labelsOperator === 'and' ? ' & ' : ' | ' 19 | // Add @ prefix to labels for Todoist API query 20 | const prefixedLabels = labels.map((label) => (label.startsWith('@') ? label : `@${label}`)) 21 | const labelStr = prefixedLabels.join(` ${operator} `) 22 | return `(${labelStr})` 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/output-schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | /** 4 | * Schema for a mapped task object returned by tools 5 | */ 6 | const TaskSchema = z.object({ 7 | id: z.string().describe('The unique ID of the task.'), 8 | content: z.string().describe('The task title/content.'), 9 | description: z.string().describe('The task description.'), 10 | dueDate: z.string().optional().describe('The due date of the task (ISO 8601 format).'), 11 | recurring: z 12 | .union([z.boolean(), z.string()]) 13 | .describe('Whether the task is recurring, or the recurrence string.'), 14 | deadlineDate: z 15 | .string() 16 | .optional() 17 | .describe('The deadline date of the task (ISO 8601 format).'), 18 | priority: z.number().describe('The priority level (1-4, where 1 is highest priority).'), 19 | projectId: z.string().describe('The ID of the project this task belongs to.'), 20 | sectionId: z.string().optional().describe('The ID of the section this task belongs to.'), 21 | parentId: z.string().optional().describe('The ID of the parent task (for subtasks).'), 22 | labels: z.array(z.string()).describe('The labels attached to this task.'), 23 | duration: z.string().optional().describe('The duration of the task (e.g., "2h30m").'), 24 | responsibleUid: z 25 | .string() 26 | .optional() 27 | .describe('The UID of the user responsible for this task.'), 28 | assignedByUid: z.string().optional().describe('The UID of the user who assigned this task.'), 29 | checked: z.boolean().describe('Whether the task is checked/completed.'), 30 | completedAt: z.string().optional().describe('When the task was completed (ISO 8601 format).'), 31 | }) 32 | 33 | /** 34 | * Schema for a mapped project object returned by tools 35 | */ 36 | const ProjectSchema = z.object({ 37 | id: z.string().describe('The unique ID of the project.'), 38 | name: z.string().describe('The name of the project.'), 39 | color: z.string().describe('The color of the project.'), 40 | isFavorite: z.boolean().describe('Whether the project is marked as favorite.'), 41 | isShared: z.boolean().describe('Whether the project is shared.'), 42 | parentId: z.string().optional().describe('The ID of the parent project (for sub-projects).'), 43 | inboxProject: z.boolean().describe('Whether this is the inbox project.'), 44 | viewStyle: z.string().describe('The view style of the project (list, board, calendar).'), 45 | }) 46 | 47 | /** 48 | * Schema for a section object returned by tools 49 | */ 50 | const SectionSchema = z.object({ 51 | id: z.string().describe('The unique ID of the section.'), 52 | name: z.string().describe('The name of the section.'), 53 | }) 54 | 55 | /** 56 | * Schema for a file attachment in a comment 57 | */ 58 | const AttachmentSchema = z.object({ 59 | resourceType: z.string().describe('The type of resource.'), 60 | fileName: z.string().optional().describe('The name of the file.'), 61 | fileSize: z.number().optional().describe('The size of the file in bytes.'), 62 | fileType: z.string().optional().describe('The MIME type of the file.'), 63 | fileUrl: z.string().optional().describe('The URL to access the file.'), 64 | fileDuration: z 65 | .number() 66 | .optional() 67 | .describe('The duration in milliseconds (for audio/video files).'), 68 | uploadState: z 69 | .enum(['pending', 'completed']) 70 | .optional() 71 | .describe('The upload state of the file.'), 72 | }) 73 | 74 | /** 75 | * Schema for a comment object returned by tools 76 | */ 77 | const CommentSchema = z.object({ 78 | id: z.string().describe('The unique ID of the comment.'), 79 | taskId: z.string().optional().describe('The ID of the task this comment belongs to.'), 80 | projectId: z.string().optional().describe('The ID of the project this comment belongs to.'), 81 | content: z.string().describe('The content of the comment.'), 82 | postedAt: z.string().describe('When the comment was posted (ISO 8601 format).'), 83 | attachment: AttachmentSchema.optional().describe('File attachment information, if any.'), 84 | }) 85 | 86 | /** 87 | * Schema for an activity event object returned by tools 88 | */ 89 | const ActivityEventSchema = z.object({ 90 | id: z.string().describe('The unique ID of the activity event.'), 91 | objectType: z 92 | .string() 93 | .describe('The type of object this event relates to (task, project, etc).'), 94 | objectId: z.string().describe('The ID of the object this event relates to.'), 95 | eventType: z.string().describe('The type of event (added, updated, deleted, completed, etc).'), 96 | eventDate: z.string().describe('When the event occurred (ISO 8601 format).'), 97 | parentProjectId: z.string().optional().describe('The ID of the parent project.'), 98 | parentItemId: z.string().optional().describe('The ID of the parent item.'), 99 | initiatorId: z.string().optional().describe('The ID of the user who initiated this event.'), 100 | extraData: z.record(z.unknown()).describe('Additional event data.'), 101 | }) 102 | 103 | /** 104 | * Schema for a user/collaborator object returned by tools 105 | */ 106 | const CollaboratorSchema = z.object({ 107 | id: z.string().describe('The unique ID of the user.'), 108 | name: z.string().describe('The full name of the user.'), 109 | email: z.string().describe('The email address of the user.'), 110 | }) 111 | 112 | /** 113 | * Schema for batch operation failure 114 | */ 115 | const FailureSchema = z.object({ 116 | item: z.string().describe('The item that failed (usually an ID or identifier).'), 117 | error: z.string().describe('The error message.'), 118 | code: z.string().optional().describe('The error code, if available.'), 119 | }) 120 | 121 | export { 122 | ActivityEventSchema, 123 | CollaboratorSchema, 124 | CommentSchema, 125 | FailureSchema, 126 | ProjectSchema, 127 | SectionSchema, 128 | TaskSchema, 129 | } 130 | -------------------------------------------------------------------------------- /src/utils/priorities.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const PRIORITY_VALUES = ['p1', 'p2', 'p3', 'p4'] as const 4 | export type Priority = (typeof PRIORITY_VALUES)[number] 5 | 6 | export const PrioritySchema = z.enum(PRIORITY_VALUES, { 7 | description: 'Task priority: p1 (highest), p2 (high), p3 (medium), p4 (lowest/default)', 8 | }) 9 | 10 | export function convertPriorityToNumber(priority: Priority): number { 11 | // Todoist API uses inverse mapping: p1=4 (highest), p2=3, p3=2, p4=1 (lowest) 12 | const priorityMap = { p1: 4, p2: 3, p3: 2, p4: 1 } 13 | return priorityMap[priority] 14 | } 15 | 16 | export function convertNumberToPriority(priority: number): Priority | undefined { 17 | // Convert Todoist API numbers back to our enum 18 | const numberMap = { 4: 'p1', 3: 'p2', 2: 'p3', 1: 'p4' } as const 19 | return numberMap[priority as keyof typeof numberMap] 20 | } 21 | 22 | export function formatPriorityForDisplay(priority: number): string { 23 | // Convert Todoist API numbers to display format (P1, P2, P3, P4) 24 | const displayMap = { 4: 'P1', 3: 'P2', 2: 'P3', 1: 'P4' } as const 25 | return displayMap[priority as keyof typeof displayMap] || '' 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/sanitize-data.test.ts: -------------------------------------------------------------------------------- 1 | import { removeNullFields } from './sanitize-data.js' 2 | 3 | describe('removeNullFields', () => { 4 | it('should remove null fields from objects including nested objects', () => { 5 | const input = { 6 | name: 'John', 7 | age: null, 8 | email: 'john@example.com', 9 | phone: null, 10 | address: { 11 | street: '123 Main St', 12 | city: null, 13 | country: 'USA', 14 | }, 15 | } 16 | 17 | const result = removeNullFields(input) 18 | 19 | expect(result).toEqual({ 20 | name: 'John', 21 | email: 'john@example.com', 22 | address: { 23 | street: '123 Main St', 24 | country: 'USA', 25 | }, 26 | }) 27 | }) 28 | 29 | it('should handle arrays with null values', () => { 30 | const input = { 31 | items: [ 32 | { id: 1, value: 'test' }, 33 | { id: 2, value: null }, 34 | { id: 3, value: 'another' }, 35 | ], 36 | } 37 | 38 | const result = removeNullFields(input) 39 | 40 | expect(result).toEqual({ 41 | items: [{ id: 1, value: 'test' }, { id: 2 }, { id: 3, value: 'another' }], 42 | }) 43 | }) 44 | 45 | it('should handle null objects', () => { 46 | const result = removeNullFields(null) 47 | expect(result).toBeNull() 48 | }) 49 | 50 | it('should remove empty objects and empty arrays', () => { 51 | const input = { 52 | something: 'hello', 53 | another: {}, 54 | yetAnother: [], 55 | } 56 | 57 | const result = removeNullFields(input) 58 | 59 | expect(result).toEqual({ 60 | something: 'hello', 61 | }) 62 | }) 63 | 64 | it('should remove empty objects and empty arrays in nested structures', () => { 65 | const input = { 66 | name: 'Test', 67 | metadata: {}, 68 | tags: [], 69 | nested: { 70 | data: 'value', 71 | emptyObj: {}, 72 | emptyArr: [], 73 | deepNested: { 74 | anotherEmpty: {}, 75 | }, 76 | }, 77 | items: [ 78 | { id: 1, data: 'test', empty: {} }, 79 | { id: 2, list: [] }, 80 | ], 81 | } 82 | 83 | const result = removeNullFields(input) 84 | 85 | expect(result).toEqual({ 86 | name: 'Test', 87 | nested: { 88 | data: 'value', 89 | }, 90 | items: [{ id: 1, data: 'test' }, { id: 2 }], 91 | }) 92 | }) 93 | 94 | it('should keep arrays with values and objects with properties', () => { 95 | const input = { 96 | emptyArray: [], 97 | arrayWithValues: [1, 2, 3], 98 | emptyObject: {}, 99 | objectWithProps: { key: 'value' }, 100 | } 101 | 102 | const result = removeNullFields(input) 103 | 104 | expect(result).toEqual({ 105 | arrayWithValues: [1, 2, 3], 106 | objectWithProps: { key: 'value' }, 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /src/utils/sanitize-data.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes all null fields, empty objects, and empty arrays from an object recursively. 3 | * This ensures that data sent to agents doesn't include unnecessary empty values. 4 | * 5 | * @param obj - The object to sanitize 6 | * @returns A new object with all null fields, empty objects, and empty arrays removed 7 | */ 8 | export function removeNullFields(obj: T): T { 9 | if (obj === null || obj === undefined) { 10 | return obj 11 | } 12 | 13 | if (Array.isArray(obj)) { 14 | return obj.map((item) => removeNullFields(item)) as T 15 | } 16 | 17 | if (typeof obj === 'object') { 18 | const sanitized: Record = {} 19 | for (const [key, value] of Object.entries(obj)) { 20 | if (value !== null) { 21 | const cleanedValue = removeNullFields(value) 22 | 23 | // Skip empty arrays 24 | if (Array.isArray(cleanedValue) && cleanedValue.length === 0) { 25 | continue 26 | } 27 | 28 | // Skip empty objects 29 | if ( 30 | cleanedValue !== null && 31 | typeof cleanedValue === 'object' && 32 | !Array.isArray(cleanedValue) && 33 | Object.keys(cleanedValue).length === 0 34 | ) { 35 | continue 36 | } 37 | 38 | sanitized[key] = cleanedValue 39 | } 40 | } 41 | return sanitized as T 42 | } 43 | 44 | return obj 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/tool-names.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Centralized tool names module 3 | * 4 | * This module provides a single source of truth for all tool names used throughout the codebase. 5 | * Each tool should import its own name from this module to avoid hardcoded strings. 6 | * This prevents outdated references when tool names change. 7 | */ 8 | export const ToolNames = { 9 | // Task management tools 10 | ADD_TASKS: 'add-tasks', 11 | COMPLETE_TASKS: 'complete-tasks', 12 | UPDATE_TASKS: 'update-tasks', 13 | FIND_TASKS: 'find-tasks', 14 | FIND_TASKS_BY_DATE: 'find-tasks-by-date', 15 | FIND_COMPLETED_TASKS: 'find-completed-tasks', 16 | 17 | // Project management tools 18 | ADD_PROJECTS: 'add-projects', 19 | UPDATE_PROJECTS: 'update-projects', 20 | FIND_PROJECTS: 'find-projects', 21 | 22 | // Section management tools 23 | ADD_SECTIONS: 'add-sections', 24 | UPDATE_SECTIONS: 'update-sections', 25 | FIND_SECTIONS: 'find-sections', 26 | 27 | // Comment management tools 28 | ADD_COMMENTS: 'add-comments', 29 | UPDATE_COMMENTS: 'update-comments', 30 | FIND_COMMENTS: 'find-comments', 31 | 32 | // Assignment and collaboration tools 33 | FIND_PROJECT_COLLABORATORS: 'find-project-collaborators', 34 | MANAGE_ASSIGNMENTS: 'manage-assignments', 35 | 36 | // Activity and audit tools 37 | FIND_ACTIVITY: 'find-activity', 38 | 39 | // General tools 40 | GET_OVERVIEW: 'get-overview', 41 | DELETE_OBJECT: 'delete-object', 42 | USER_INFO: 'user-info', 43 | 44 | // OpenAI MCP tools 45 | SEARCH: 'search', 46 | FETCH: 'fetch', 47 | } as const 48 | 49 | // Type for all tool names 50 | export type ToolName = (typeof ToolNames)[keyof typeof ToolNames] 51 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Language and Environment */ 4 | "target": "es2021", 5 | "lib": ["es2021"], 6 | 7 | /* Modules */ 8 | "module": "ESNext", 9 | "moduleResolution": "Node", 10 | "allowImportingTsExtensions": false, 11 | "rootDir": "src", 12 | "outDir": "dist", 13 | "resolveJsonModule": true, 14 | 15 | /* Type Checking */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | /* Interop */ 25 | "esModuleInterop": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "verbatimModuleSyntax": false, 28 | "isolatedModules": true, 29 | 30 | /* Other */ 31 | "skipLibCheck": true, 32 | "declaration": true, 33 | "declarationMap": true, 34 | 35 | /* Vitest types */ 36 | "types": ["vitest/globals"] 37 | }, 38 | "include": ["src"] 39 | } 40 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import dts from 'vite-plugin-dts' 3 | import { defineConfig } from 'vitest/config' 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | dts({ 8 | include: ['src/**/*'], 9 | exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts'], 10 | entryRoot: 'src', 11 | }), 12 | ], 13 | 14 | // Build configuration for library mode 15 | build: { 16 | lib: { 17 | // Multiple entry points for CLI and library 18 | entry: { 19 | main: resolve(__dirname, 'src/main.ts'), 20 | index: resolve(__dirname, 'src/index.ts'), 21 | }, 22 | formats: ['es'], // ESM only (matches package.json "type": "module") 23 | fileName: (_format, entryName) => `${entryName}.js`, 24 | }, 25 | rollupOptions: { 26 | // Externalize dependencies to avoid bundling them 27 | external: [ 28 | '@modelcontextprotocol/sdk', 29 | '@doist/todoist-api-typescript', 30 | 'date-fns', 31 | 'dotenv', 32 | 'zod', 33 | // Node.js built-ins (both forms) 34 | 'node:path', 35 | 'node:fs', 36 | 'node:url', 37 | 'node:process', 38 | 'path', 39 | 'fs', 40 | 'url', 41 | 'process', 42 | /^node:/, 43 | ], 44 | output: { 45 | // Generate declarations separately 46 | preserveModules: false, 47 | }, 48 | }, 49 | target: 'node18', // Target Node.js 18+ 50 | outDir: 'dist', 51 | emptyOutDir: true, 52 | sourcemap: false, 53 | ssr: true, // Server-side rendering mode for Node.js 54 | minify: true, 55 | }, 56 | 57 | // Enable ?raw imports for future HTML/CSS template loading 58 | assetsInclude: ['**/*.html', '**/*.css'], 59 | 60 | // Test configuration with Vitest 61 | test: { 62 | globals: true, // Enable global test APIs (describe, it, expect, etc.) 63 | environment: 'node', 64 | include: ['src/**/*.{test,spec}.{js,ts}'], 65 | exclude: ['node_modules', 'dist'], 66 | // Optimize for CI - avoid unnecessary bundling 67 | server: { 68 | deps: { 69 | external: ['rollup'], 70 | }, 71 | }, 72 | coverage: { 73 | provider: 'v8', 74 | include: ['src/**/*.ts'], 75 | exclude: [ 76 | 'src/**/*.d.ts', 77 | 'src/main.ts', // Exclude the MCP server entry point 78 | 'src/**/*.test.ts', 79 | 'src/**/*.spec.ts', 80 | ], 81 | reporter: ['text', 'json', 'html'], 82 | }, 83 | }, 84 | 85 | // Resolve configuration 86 | resolve: { 87 | alias: { 88 | // Enable clean imports within src/ 89 | '@': resolve(__dirname, 'src'), 90 | }, 91 | }, 92 | 93 | // Ensure proper handling of ESM for Node.js 94 | esbuild: { 95 | target: 'node18', 96 | format: 'esm', 97 | platform: 'node', 98 | }, 99 | }) 100 | --------------------------------------------------------------------------------