├── .claude └── settings.json ├── .cursor ├── mcp.json └── rules │ ├── context7.mdc │ └── typescript.mdc ├── .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 ├── jest.config.js ├── openai-mcp-tools.md ├── package-lock.json ├── package.json ├── renovate.json ├── scripts └── test-executable.cjs ├── src ├── 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-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-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-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 │ ├── priorities.ts │ ├── response-builders.ts │ ├── sanitize-data.test.ts │ ├── sanitize-data.ts │ ├── test-helpers.ts │ ├── tool-names.ts │ └── user-resolver.ts └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.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@v5 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: Test MCP server executable 58 | run: npm run test:executable 59 | env: 60 | TODOIST_API_KEY: fake-api-key-for-testing 61 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release @doist/todoist-ai package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | # Enable the use of OIDC for npm provenance 10 | contents: read 11 | id-token: write 12 | 13 | jobs: 14 | publish: 15 | # Only run if a release was published or workflow was manually triggered 16 | if: ${{ github.event.action == 'published' || github.event_name == 'workflow_dispatch' }} 17 | runs-on: ubuntu-latest 18 | # Based on historical data 19 | timeout-minutes: 60 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v5 23 | 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v5 26 | with: 27 | node-version-file: ".nvmrc" 28 | registry-url: "https://registry.npmjs.org" 29 | 30 | - name: Install dependencies 31 | run: npm ci 32 | 33 | - name: Build package 34 | run: npm run build 35 | 36 | - name: Type check 37 | run: npm run type-check 38 | 39 | - name: Lint check 40 | run: npm run lint:check 41 | 42 | - name: Format check 43 | run: npm run format:check 44 | 45 | - name: Publish to npm 46 | run: npm publish --provenance --access public 47 | env: 48 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | -------------------------------------------------------------------------------- /.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 | 13 | jobs: 14 | release-please: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: googleapis/release-please-action@v4 18 | id: release 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | release-type: node 22 | config-file: .github/release-please-config.json 23 | manifest-file: .github/release-please-manifest.json 24 | 25 | - name: Publish draft release 26 | if: ${{ steps.release.outputs.release_created }} 27 | run: | 28 | RELEASE_TAG="${{ steps.release.outputs.tag_name }}" 29 | echo "Publishing release $RELEASE_TAG" 30 | gh release edit "$RELEASE_TAG" --draft=false 31 | env: 32 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | .DS_Store 5 | *.log* 6 | .npm 7 | *.tgz 8 | .claude/settings.local.json -------------------------------------------------------------------------------- /.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 { streamText } from "ai"; 24 | 25 | const result = streamText({ 26 | model: yourModel, 27 | system: "You are a helpful Todoist assistant", 28 | tools: { 29 | findTasksByDate, 30 | addTasks, 31 | }, 32 | }); 33 | ``` 34 | 35 | ## Using as an MCP server 36 | 37 | ### Quick Start 38 | 39 | You can run the MCP server directly with npx: 40 | 41 | ```bash 42 | npx @doist/todoist-ai 43 | ``` 44 | 45 | ### Setup Guide 46 | 47 | The Todoist AI MCP server is available as a streamable HTTP service for easy integration with various AI clients: 48 | 49 | **Primary URL (Streamable HTTP):** `https://ai.todoist.net/mcp` 50 | 51 | #### Claude Desktop 52 | 53 | 1. Open Settings → Connectors → Add custom connector 54 | 2. Enter `https://ai.todoist.net/mcp` and complete OAuth authentication 55 | 56 | #### Cursor 57 | 58 | Create a configuration file: 59 | - **Global:** `~/.cursor/mcp.json` 60 | - **Project-specific:** `.cursor/mcp.json` 61 | 62 | ```json 63 | { 64 | "mcpServers": { 65 | "todoist": { 66 | "command": "npx", 67 | "args": ["-y", "mcp-remote", "https://ai.todoist.net/mcp"] 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | Then enable the server in Cursor settings if prompted. 74 | 75 | #### Claude Code (CLI) 76 | 77 | Firstly configure Claude so it has a new MCP available using this command: 78 | 79 | ```bash 80 | claude mcp add --transport http todoist https://ai.todoist.net/mcp 81 | ``` 82 | 83 | Then launch `claude`, execute `/mcp`, then select the `todoist` MCP server. 84 | 85 | 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`. 86 | 87 | 88 | #### Visual Studio Code 89 | 90 | 1. Open Command Palette → MCP: Add Server 91 | 2. Select HTTP transport and use: 92 | 93 | ```json 94 | { 95 | "servers": { 96 | "todoist": { 97 | "type": "http", 98 | "url": "https://ai.todoist.net/mcp" 99 | } 100 | } 101 | } 102 | ``` 103 | 104 | #### Other MCP Clients 105 | 106 | ```bash 107 | npx -y mcp-remote https://ai.todoist.net/mcp 108 | ``` 109 | 110 | For more details on setting up and using the MCP server, including creating custom servers, see [docs/mcp-server.md](docs/mcp-server.md). 111 | 112 | ## Features 113 | 114 | 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). 115 | 116 | This project is in its early stages. Expect more and/or better tools soon. 117 | 118 | 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. 119 | 120 | For our design philosophy, guidelines, and development patterns, see [docs/tool-design.md](docs/tool-design.md). 121 | 122 | ### Available Tools 123 | 124 | For a complete list of available tools, see the [src/tools](src/tools) directory. 125 | 126 | #### OpenAI MCP Compatibility 127 | 128 | 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. 129 | 130 | ## Dependencies 131 | 132 | - MCP server using the official [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#installation) 133 | - Todoist Typescript API client [@doist/todoist-api-typescript](https://github.com/Doist/todoist-api-typescript) 134 | 135 | ## MCP Server Setup 136 | 137 | See [docs/mcp-server.md](docs/mcp-server.md) for full instructions on setting up the MCP server. 138 | 139 | ## Local Development Setup 140 | 141 | See [docs/dev-setup.md](docs/dev-setup.md) for full instructions on setting up this repository locally for development and contributing. 142 | 143 | ### Quick Start 144 | 145 | After cloning and setting up the repository: 146 | 147 | - `npm start` - Build and run the MCP inspector for testing 148 | - `npm run dev` - Development mode with auto-rebuild and restart 149 | 150 | ## Releasing 151 | 152 | This project uses [release-please](https://github.com/googleapis/release-please) to automate version management and package publishing. 153 | 154 | ### How it works 155 | 156 | 1. Make your changes using [Conventional Commits](https://www.conventionalcommits.org/): 157 | 158 | - `feat:` for new features (minor version bump) 159 | - `fix:` for bug fixes (patch version bump) 160 | - `feat!:` or `fix!:` for breaking changes (major version bump) 161 | - `docs:` for documentation changes 162 | - `chore:` for maintenance tasks 163 | - `ci:` for CI changes 164 | 165 | 2. When commits are pushed to `main`: 166 | 167 | - Release-please automatically creates/updates a release PR 168 | - The PR includes version bump and changelog updates 169 | - Review the PR and merge when ready 170 | 171 | 3. After merging the release PR: 172 | - A new GitHub release is automatically created 173 | - A new tag is created 174 | - The `publish` workflow is triggered 175 | - The package is published to npm 176 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.4/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 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest/presets/default-esm', 3 | extensionsToTreatAsEsm: ['.ts'], 4 | testEnvironment: 'node', 5 | roots: ['/src'], 6 | testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], 7 | moduleNameMapper: { 8 | '^(\\.{1,2}/.*)\\.js$': '$1', 9 | }, 10 | transform: { 11 | '^.+\\.ts$': [ 12 | 'ts-jest', 13 | { 14 | useESM: true, 15 | }, 16 | ], 17 | }, 18 | collectCoverageFrom: [ 19 | 'src/**/*.ts', 20 | '!src/**/*.d.ts', 21 | '!src/main.ts', // Exclude the MCP server entry point 22 | ], 23 | } 24 | -------------------------------------------------------------------------------- /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.11.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": "jest", 31 | "build": "rimraf dist && npx tsc --project tsconfig.json", 32 | "postbuild": "chmod +x dist/main.js", 33 | "start": "npm run build && npx @modelcontextprotocol/inspector node dist/main.js", 34 | "dev": "concurrently \"npx tsc --watch\" \"npx @modelcontextprotocol/inspector npx nodemon --quiet --watch dist --ext js --exec node dist/main.js\"", 35 | "setup": "cp .env.example .env && npm install && npm run build", 36 | "test:executable": "npm run build && node scripts/test-executable.cjs", 37 | "type-check": "npx tsc --noEmit", 38 | "biome:sort-imports": "biome check --formatter-enabled=false --linter-enabled=false --organize-imports-enabled=true --write .", 39 | "lint:check": "biome lint", 40 | "lint:write": "biome lint --write", 41 | "format:check": "biome format", 42 | "format:write": "biome format --write", 43 | "check": "biome check", 44 | "check:fix": "biome check --fix --unsafe", 45 | "prepare": "husky" 46 | }, 47 | "dependencies": { 48 | "@doist/todoist-api-typescript": "5.5.1", 49 | "@modelcontextprotocol/sdk": "^1.11.1", 50 | "date-fns": "^4.1.0", 51 | "dotenv": "^16.5.0", 52 | "zod": "^3.25.7" 53 | }, 54 | "devDependencies": { 55 | "@biomejs/biome": "2.2.5", 56 | "@types/express": "^5.0.2", 57 | "@types/jest": "30.0.0", 58 | "@types/morgan": "^1.9.9", 59 | "@types/node": "^22.15.17", 60 | "concurrently": "^9.0.0", 61 | "express": "^5.0.0", 62 | "husky": "^9.1.7", 63 | "jest": "30.2.0", 64 | "lint-staged": "^16.0.0", 65 | "morgan": "^1.10.0", 66 | "nodemon": "^3.1.10", 67 | "rimraf": "^6.0.1", 68 | "ts-jest": "29.4.4", 69 | "typescript": "^5.8.3" 70 | }, 71 | "lint-staged": { 72 | "*": [ 73 | "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true" 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", ":semanticCommitTypeAll(chore)"], 4 | "schedule": ["before 4am on Monday"], 5 | "timezone": "UTC", 6 | "labels": ["dependencies"], 7 | "assigneesFromCodeOwners": true, 8 | "reviewersFromCodeOwners": true, 9 | "packageRules": [ 10 | { 11 | "matchPackageNames": ["*"], 12 | "groupName": "all dependencies", 13 | "groupSlug": "all" 14 | }, 15 | { 16 | "matchDepTypes": ["devDependencies"], 17 | "groupName": "dev dependencies", 18 | "groupSlug": "dev" 19 | }, 20 | { 21 | "matchDepTypes": ["dependencies"], 22 | "groupName": "production dependencies", 23 | "groupSlug": "prod" 24 | } 25 | ], 26 | "vulnerabilityAlerts": { 27 | "enabled": true 28 | }, 29 | "lockFileMaintenance": { 30 | "enabled": true, 31 | "schedule": ["before 4am on Monday"] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /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/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 | import { findComments } from './tools/find-comments.js' 15 | import { findCompletedTasks } from './tools/find-completed-tasks.js' 16 | // Assignment and collaboration tools 17 | import { findProjectCollaborators } from './tools/find-project-collaborators.js' 18 | import { findProjects } from './tools/find-projects.js' 19 | import { findSections } from './tools/find-sections.js' 20 | import { findTasks } from './tools/find-tasks.js' 21 | import { findTasksByDate } from './tools/find-tasks-by-date.js' 22 | import { getOverview } from './tools/get-overview.js' 23 | import { manageAssignments } from './tools/manage-assignments.js' 24 | import { search } from './tools/search.js' 25 | import { updateComments } from './tools/update-comments.js' 26 | import { updateProjects } from './tools/update-projects.js' 27 | import { updateSections } from './tools/update-sections.js' 28 | import { updateTasks } from './tools/update-tasks.js' 29 | import { userInfo } from './tools/user-info.js' 30 | 31 | const tools = { 32 | // Task management tools 33 | addTasks, 34 | completeTasks, 35 | updateTasks, 36 | findTasks, 37 | findTasksByDate, 38 | findCompletedTasks, 39 | // Project management tools 40 | addProjects, 41 | updateProjects, 42 | findProjects, 43 | // Section management tools 44 | addSections, 45 | updateSections, 46 | findSections, 47 | // Comment management tools 48 | addComments, 49 | updateComments, 50 | findComments, 51 | // General tools 52 | getOverview, 53 | deleteObject, 54 | userInfo, 55 | // Assignment and collaboration tools 56 | findProjectCollaborators, 57 | manageAssignments, 58 | // OpenAI MCP tools 59 | search, 60 | fetch, 61 | } 62 | 63 | export { tools, getMcpServer } 64 | 65 | export { 66 | // Task management tools 67 | addTasks, 68 | completeTasks, 69 | updateTasks, 70 | findTasks, 71 | findTasksByDate, 72 | findCompletedTasks, 73 | // Project management tools 74 | addProjects, 75 | updateProjects, 76 | findProjects, 77 | // Section management tools 78 | addSections, 79 | updateSections, 80 | findSections, 81 | // Comment management tools 82 | addComments, 83 | updateComments, 84 | findComments, 85 | // General tools 86 | getOverview, 87 | deleteObject, 88 | userInfo, 89 | // Assignment and collaboration tools 90 | findProjectCollaborators, 91 | manageAssignments, 92 | // OpenAI MCP tools 93 | search, 94 | fetch, 95 | } 96 | -------------------------------------------------------------------------------- /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 | server.tool(tool.name, tool.description, tool.parameters, cb) 94 | } 95 | 96 | export { registerTool, getErrorOutput, getToolOutput } 97 | -------------------------------------------------------------------------------- /src/mcp-server.ts: -------------------------------------------------------------------------------- 1 | import { TodoistApi } from '@doist/todoist-api-typescript' 2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 3 | import { registerTool } from './mcp-helpers.js' 4 | import { addComments } from './tools/add-comments.js' 5 | import { addProjects } from './tools/add-projects.js' 6 | import { addSections } from './tools/add-sections.js' 7 | import { addTasks } from './tools/add-tasks.js' 8 | import { completeTasks } from './tools/complete-tasks.js' 9 | import { deleteObject } from './tools/delete-object.js' 10 | import { fetch } from './tools/fetch.js' 11 | import { findComments } from './tools/find-comments.js' 12 | import { findCompletedTasks } from './tools/find-completed-tasks.js' 13 | import { findProjectCollaborators } from './tools/find-project-collaborators.js' 14 | import { findProjects } from './tools/find-projects.js' 15 | import { findSections } from './tools/find-sections.js' 16 | import { findTasks } from './tools/find-tasks.js' 17 | import { findTasksByDate } from './tools/find-tasks-by-date.js' 18 | import { getOverview } from './tools/get-overview.js' 19 | import { manageAssignments } from './tools/manage-assignments.js' 20 | import { search } from './tools/search.js' 21 | import { updateComments } from './tools/update-comments.js' 22 | import { updateProjects } from './tools/update-projects.js' 23 | import { updateSections } from './tools/update-sections.js' 24 | import { updateTasks } from './tools/update-tasks.js' 25 | import { userInfo } from './tools/user-info.js' 26 | 27 | const instructions = ` 28 | ## Todoist Task and Project Management Tools 29 | 30 | You have access to comprehensive Todoist management tools for personal productivity and team collaboration. Use these tools to help users manage tasks, projects, sections, comments, and assignments effectively. 31 | 32 | ### Core Capabilities: 33 | - Create, update, complete, and search tasks with rich metadata (priorities, due dates, durations, assignments) 34 | - Manage projects and sections with flexible organization 35 | - Handle comments and collaboration features 36 | - Bulk assignment operations for team workflows 37 | - Get overviews and insights about workload and progress 38 | 39 | ### Tool Usage Guidelines: 40 | 41 | **Task Management:** 42 | - **add-tasks**: Create tasks with content, description, priority (p1=highest, p2=high, p3=medium, p4=lowest/default), dueString (natural language like "tomorrow", "next Friday", "2024-12-25"), duration (formats like "2h", "90m", "2h30m"), and assignments to project collaborators 43 | - **update-tasks**: Modify existing tasks - get task IDs from search results first, only include fields that need changes 44 | - **complete-tasks**: Mark tasks as done using task IDs 45 | - **find-tasks**: Search by text, project/section/parent container, responsible user, or labels. Requires at least one search parameter 46 | - **find-tasks-by-date**: Get tasks by date range (startDate: YYYY-MM-DD or 'today' which includes overdue tasks) or specific day counts 47 | - **find-completed-tasks**: View completed tasks by completion date or original due date 48 | 49 | **Project & Organization:** 50 | - **add-projects/update-projects/find-projects**: Manage project lifecycle with names, favorites, and view styles (list/board/calendar) 51 | - **add-sections/update-sections/find-sections**: Organize tasks within projects using sections 52 | - **get-overview**: Get comprehensive Markdown overview of entire account or specific project with task hierarchies 53 | 54 | **Collaboration & Comments:** 55 | - **add-comments/update-comments/find-comments**: Manage task and project discussions 56 | - **find-project-collaborators**: Find team members by name or email for assignments 57 | - **manage-assignments**: Bulk assign/unassign/reassign up to 50 tasks with atomic operations and dry-run validation 58 | 59 | **General Operations:** 60 | - **delete-object**: Remove projects, sections, tasks, or comments by type and ID 61 | - **user-info**: Get user details including timezone, goals, and plan information 62 | 63 | ### Best Practices: 64 | 65 | 1. **Task Creation**: Write clear, actionable task titles. Use natural language for due dates ("tomorrow", "next Monday"). Set appropriate priorities and include detailed descriptions when needed. 66 | 67 | 2. **Search Strategy**: Use specific search queries combining multiple filters for precise results. When searching for tasks, start with broader queries and narrow down as needed. 68 | 69 | 3. **Assignments**: Always validate project collaborators exist before assigning tasks. Use find-project-collaborators to verify user access. 70 | 71 | 4. **Bulk Operations**: When working with multiple items, prefer bulk tools (complete-tasks, manage-assignments) over individual operations for better performance. 72 | 73 | 5. **Date Handling**: All dates respect user timezone settings. Use 'today' keyword for dynamic date filtering (includes overdue tasks). 74 | 75 | 6. **Labels**: Use label filtering with AND/OR operators for advanced task organization. Most search tools support labels parameter. 76 | 77 | 7. **Pagination**: Large result sets use cursor-based pagination. Use limit parameter to control result size (default varies by tool). 78 | 79 | 8. **Error Handling**: All tools provide detailed error messages and next-step suggestions. Pay attention to validation feedback for corrective actions. 80 | 81 | ### Common Workflows: 82 | 83 | - **Daily Planning**: Use find-tasks-by-date with 'today' and get-overview for project status 84 | - **Team Assignment**: find-project-collaborators → add-tasks with responsibleUser → manage-assignments for bulk changes 85 | - **Task Search**: find-tasks with multiple filters → update-tasks or complete-tasks based on results 86 | - **Project Organization**: add-projects → add-sections → add-tasks with projectId and sectionId 87 | - **Progress Reviews**: find-completed-tasks with date ranges → get-overview for project summaries 88 | 89 | Always provide clear, actionable task titles and descriptions. Use the overview tools to give users context about their workload and project status. 90 | ` 91 | 92 | /** 93 | * Create the MCP server. 94 | * @param todoistApiKey - The API key for the todoist account. 95 | * @param baseUrl - The base URL for the todoist API. 96 | * @returns the MCP server. 97 | */ 98 | function getMcpServer({ todoistApiKey, baseUrl }: { todoistApiKey: string; baseUrl?: string }) { 99 | const server = new McpServer( 100 | { name: 'todoist-mcp-server', version: '0.1.0' }, 101 | { 102 | capabilities: { 103 | tools: { listChanged: true }, 104 | }, 105 | instructions, 106 | }, 107 | ) 108 | 109 | const todoist = new TodoistApi(todoistApiKey, baseUrl) 110 | 111 | // Task management tools 112 | registerTool(addTasks, server, todoist) 113 | registerTool(completeTasks, server, todoist) 114 | registerTool(updateTasks, server, todoist) 115 | registerTool(findTasks, server, todoist) 116 | registerTool(findTasksByDate, server, todoist) 117 | registerTool(findCompletedTasks, server, todoist) 118 | 119 | // Project management tools 120 | registerTool(addProjects, server, todoist) 121 | registerTool(updateProjects, server, todoist) 122 | registerTool(findProjects, server, todoist) 123 | 124 | // Section management tools 125 | registerTool(addSections, server, todoist) 126 | registerTool(updateSections, server, todoist) 127 | registerTool(findSections, server, todoist) 128 | 129 | // Comment management tools 130 | registerTool(addComments, server, todoist) 131 | registerTool(findComments, server, todoist) 132 | registerTool(updateComments, server, todoist) 133 | 134 | // General tools 135 | registerTool(getOverview, server, todoist) 136 | registerTool(deleteObject, server, todoist) 137 | registerTool(userInfo, server, todoist) 138 | 139 | // Assignment and collaboration tools 140 | registerTool(findProjectCollaborators, server, todoist) 141 | registerTool(manageAssignments, server, todoist) 142 | 143 | // OpenAI MCP tools 144 | registerTool(search, server, todoist) 145 | registerTool(fetch, server, todoist) 146 | 147 | return server 148 | } 149 | 150 | export { getMcpServer } 151 | -------------------------------------------------------------------------------- /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 function that executes the tool. 29 | * 30 | * This is the main function that will be called when the tool is used. 31 | * 32 | * @param args - The arguments of the tool. 33 | * @param client - The Todoist API client used to make requests to the Todoist API. 34 | * @returns The result of the tool. 35 | */ 36 | execute: (args: z.infer>, client: TodoistApi) => Promise 37 | } 38 | 39 | export type { TodoistTool } 40 | -------------------------------------------------------------------------------- /src/tool-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | MoveTaskArgs, 3 | PersonalProject, 4 | Task, 5 | TodoistApi, 6 | WorkspaceProject, 7 | } from '@doist/todoist-api-typescript' 8 | import z from 'zod' 9 | import { formatDuration } from './utils/duration-parser.js' 10 | 11 | export const RESPONSIBLE_USER_FILTERING = ['assigned', 'unassignedOrMe', 'all'] as const 12 | export type ResponsibleUserFiltering = (typeof RESPONSIBLE_USER_FILTERING)[number] 13 | 14 | export type Project = PersonalProject | WorkspaceProject 15 | 16 | export function isPersonalProject(project: Project): project is PersonalProject { 17 | return 'inboxProject' in project 18 | } 19 | 20 | export function isWorkspaceProject(project: Project): project is WorkspaceProject { 21 | return 'accessLevel' in project 22 | } 23 | 24 | /** 25 | * Filters tasks based on responsible user logic: 26 | * - If resolvedAssigneeId is provided: returns only tasks assigned to that user 27 | * - If no resolvedAssigneeId: returns only unassigned tasks or tasks assigned to current user 28 | * @param tasks - Array of tasks to filter (must have responsibleUid property) 29 | * @param resolvedAssigneeId - The resolved assignee ID to filter by (optional) 30 | * @param currentUserId - The current authenticated user's ID 31 | * @returns Filtered array of tasks 32 | */ 33 | export function filterTasksByResponsibleUser({ 34 | tasks, 35 | resolvedAssigneeId, 36 | currentUserId, 37 | responsibleUserFiltering = 'unassignedOrMe', 38 | }: { 39 | tasks: T[] 40 | resolvedAssigneeId: string | undefined 41 | currentUserId: string 42 | responsibleUserFiltering?: ResponsibleUserFiltering 43 | }): T[] { 44 | if (resolvedAssigneeId) { 45 | // If responsibleUser provided, only return tasks assigned to that user 46 | return tasks.filter((task) => task.responsibleUid === resolvedAssigneeId) 47 | } else { 48 | // If no responsibleUser, only return unassigned tasks or tasks assigned to current user 49 | return responsibleUserFiltering === 'unassignedOrMe' 50 | ? tasks.filter((task) => !task.responsibleUid || task.responsibleUid === currentUserId) 51 | : tasks 52 | } 53 | } 54 | 55 | /** 56 | * Creates a MoveTaskArgs object from move parameters, validating that exactly one is provided. 57 | * @param taskId - The task ID (used for error messages) 58 | * @param projectId - Optional project ID to move to 59 | * @param sectionId - Optional section ID to move to 60 | * @param parentId - Optional parent ID to move to 61 | * @returns MoveTaskArgs object with exactly one destination 62 | * @throws Error if multiple move parameters are provided or none are provided 63 | */ 64 | export function createMoveTaskArgs( 65 | taskId: string, 66 | projectId?: string, 67 | sectionId?: string, 68 | parentId?: string, 69 | ): MoveTaskArgs { 70 | // Validate that only one move parameter is provided (RequireExactlyOne constraint) 71 | const moveParams = [projectId, sectionId, parentId].filter(Boolean) 72 | if (moveParams.length > 1) { 73 | throw new Error( 74 | `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.`, 75 | ) 76 | } 77 | 78 | if (moveParams.length === 0) { 79 | throw new Error( 80 | `Task ${taskId}: At least one of projectId, sectionId, or parentId must be provided for move operations.`, 81 | ) 82 | } 83 | 84 | // Build moveArgs with the single defined value 85 | if (projectId) return { projectId } 86 | if (sectionId) return { sectionId } 87 | if (parentId) return { parentId } 88 | 89 | // This should never be reached due to the validation above 90 | throw new Error('Unexpected error: No valid move parameter found') 91 | } 92 | 93 | /** 94 | * Map a single Todoist task to a more structured format, for LLM consumption. 95 | * @param task - The task to map. 96 | * @returns The mapped task. 97 | */ 98 | function mapTask(task: Task) { 99 | return { 100 | id: task.id, 101 | content: task.content, 102 | description: task.description, 103 | dueDate: task.due?.date, 104 | recurring: task.due?.isRecurring && task.due.string ? task.due.string : false, 105 | priority: task.priority, 106 | projectId: task.projectId, 107 | sectionId: task.sectionId, 108 | parentId: task.parentId, 109 | labels: task.labels, 110 | duration: task.duration ? formatDuration(task.duration.amount) : null, 111 | responsibleUid: task.responsibleUid, 112 | assignedByUid: task.assignedByUid, 113 | } 114 | } 115 | 116 | /** 117 | * Map a single Todoist project to a more structured format, for LLM consumption. 118 | * @param project - The project to map. 119 | * @returns The mapped project. 120 | */ 121 | function mapProject(project: Project) { 122 | return { 123 | id: project.id, 124 | name: project.name, 125 | color: project.color, 126 | isFavorite: project.isFavorite, 127 | isShared: project.isShared, 128 | parentId: isPersonalProject(project) ? (project.parentId ?? null) : null, 129 | inboxProject: isPersonalProject(project) ? (project.inboxProject ?? false) : false, 130 | viewStyle: project.viewStyle, 131 | } 132 | } 133 | 134 | const ErrorSchema = z.object({ 135 | httpStatusCode: z.number(), 136 | responseData: z.object({ 137 | error: z.string(), 138 | errorCode: z.number(), 139 | errorTag: z.string(), 140 | }), 141 | }) 142 | 143 | async function getTasksByFilter({ 144 | client, 145 | query, 146 | limit, 147 | cursor, 148 | }: { 149 | client: TodoistApi 150 | query: string 151 | limit: number | undefined 152 | cursor: string | undefined 153 | }) { 154 | try { 155 | const { results, nextCursor } = await client.getTasksByFilter({ query, cursor, limit }) 156 | const tasks = results.map(mapTask) 157 | return { tasks, nextCursor } 158 | } catch (error) { 159 | const parsedError = ErrorSchema.safeParse(error) 160 | if (!parsedError.success) { 161 | throw error 162 | } 163 | const { responseData } = parsedError.data 164 | if (responseData.errorTag === 'INVALID_SEARCH_QUERY') { 165 | throw new Error(`Invalid filter query: ${query}`) 166 | } 167 | throw new Error( 168 | `${responseData.error} (tag: ${responseData.errorTag}, code: ${responseData.errorCode})`, 169 | ) 170 | } 171 | } 172 | 173 | /** 174 | * Build a Todoist URL for a task or project. 175 | * @param type - The type of object ('task' or 'project') 176 | * @param id - The ID of the object 177 | * @returns The URL string 178 | */ 179 | function buildTodoistUrl(type: 'task' | 'project', id: string): string { 180 | return `https://app.todoist.com/app/${type}/${id}` 181 | } 182 | 183 | export { getTasksByFilter, mapTask, mapProject, buildTodoistUrl } 184 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/add-comments.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`add-comments tool adding comments to projects should add comment to project 1`] = ` 4 | "Added 1 project comment 5 | Next: 6 | - Use find-comments with projectId=project789 to see all comments 7 | - Use update-comments with id=98767 to edit content 8 | - Use delete-object with type=comment to remove comments" 9 | `; 10 | 11 | exports[`add-comments tool adding comments to tasks should add comment to task 1`] = ` 12 | "Added 1 task comment 13 | Next: 14 | - Use find-comments with taskId=task456 to see all comments 15 | - Use update-comments with id=98765 to edit content 16 | - Use delete-object with type=comment to remove comments" 17 | `; 18 | 19 | exports[`add-comments tool bulk operations should add multiple comments to different entities (task + project) 1`] = ` 20 | "Added 1 task comment and 1 project comment 21 | Next: 22 | - Use find-comments to view comments by task or project 23 | - Use update-comments to edit any comment content 24 | - Use delete-object with type=comment to remove comments" 25 | `; 26 | 27 | exports[`add-comments tool bulk operations should add multiple comments to different tasks 1`] = ` 28 | "Added 2 task comments 29 | Next: 30 | - Use find-comments to view comments by task or project 31 | - Use update-comments to edit any comment content 32 | - Use delete-object with type=comment to remove comments" 33 | `; 34 | 35 | exports[`add-comments tool bulk operations should add multiple comments to the same task 1`] = ` 36 | "Added 2 task comments 37 | Next: 38 | - Use find-comments to view comments by task or project 39 | - Use update-comments to edit any comment content 40 | - Use delete-object with type=comment to remove comments" 41 | `; 42 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/add-projects.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 7 | - Use add-sections to organize new project with sections 8 | - Use add-tasks to add your first tasks to this project 9 | - Use get-overview with projectId=6cfCcrrCFg2xP94Q to see project structure" 10 | `; 11 | 12 | exports[`add-projects tool creating a single project should create project with isFavorite and viewStyle options 1`] = ` 13 | "Added 1 project: 14 | • Board Project (id=project-789) 15 | Next: 16 | - Use add-sections to organize new project with sections 17 | - Use add-tasks to add your first tasks to this project 18 | - Use get-overview with projectId=project-789 to see project structure" 19 | `; 20 | 21 | exports[`add-projects tool creating a single project should create project with parentId to create a sub-project 1`] = ` 22 | "Added 1 project: 23 | • Child Project (id=project-child) 24 | Next: 25 | - Use add-sections to organize new project with sections 26 | - Use add-tasks to add your first tasks to this project 27 | - Use get-overview with projectId=project-child to see project structure" 28 | `; 29 | 30 | exports[`add-projects tool creating a single project should handle different project properties from API 1`] = ` 31 | "Added 1 project: 32 | • My Blue Project (id=project-456) 33 | Next: 34 | - Use add-sections to organize new project with sections 35 | - Use add-tasks to add your first tasks to this project 36 | - Use get-overview with projectId=project-456 to see project structure" 37 | `; 38 | 39 | exports[`add-projects tool creating multiple projects should create multiple projects and return mapped results 1`] = ` 40 | "Added 3 projects: 41 | • First Project (id=project-1) 42 | • Second Project (id=project-2) 43 | • Third Project (id=project-3) 44 | Next: 45 | - Use add-sections to organize these projects with sections 46 | - Use add-tasks to add tasks to these projects 47 | - Use find-projects to see all projects including the new ones 48 | - Use get-overview to see updated project hierarchy" 49 | `; 50 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/add-sections.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 7 | - Use add-tasks with sectionId=section-123 to add your first tasks 8 | - Use find-tasks with sectionId=section-123 to verify setup 9 | - Use get-overview with projectId=6cfCcrrCFg2xP94Q to see project organization" 10 | `; 11 | 12 | exports[`add-sections tool creating a single section should handle different section properties from API 1`] = ` 13 | "Added 1 section: 14 | • My Section Name (id=section-456, projectId=project-789) 15 | Next: 16 | - Use add-tasks with sectionId=section-456 to add your first tasks 17 | - Use find-tasks with sectionId=section-456 to verify setup 18 | - Use get-overview with projectId=project-789 to see project organization" 19 | `; 20 | 21 | exports[`add-sections tool creating multiple sections should create multiple sections and return mapped results 1`] = ` 22 | "Added 3 sections: 23 | • First Section (id=section-1, projectId=6cfCcrrCFg2xP94Q) 24 | • Second Section (id=section-2, projectId=6cfCcrrCFg2xP94Q) 25 | • Third Section (id=section-3, projectId=different-project) 26 | Next: 27 | - Use add-tasks to add tasks to these new sections 28 | - Use get-overview to see updated project structures 29 | - Use find-sections to see sections in specific projects" 30 | `; 31 | 32 | exports[`add-sections tool creating multiple sections should handle sections for the same project 1`] = ` 33 | "Added 2 sections: 34 | • To Do (id=section-1, projectId=6cfCcrrCFg2xP94Q) 35 | • In Progress (id=section-2, projectId=6cfCcrrCFg2xP94Q) 36 | Next: 37 | - Use add-tasks to add tasks to these new sections 38 | - Use get-overview with projectId=6cfCcrrCFg2xP94Q to see updated project structure 39 | - Use find-sections with projectId=6cfCcrrCFg2xP94Q to see all sections" 40 | `; 41 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/add-tasks.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 9 | - Use get-overview to see your updated project organization" 10 | `; 11 | 12 | exports[`add-tasks tool adding multiple tasks should add tasks with duration 1`] = ` 13 | "Added 2 tasks to projects. 14 | Tasks: 15 | Task with 2 hour duration • P4 • id=8485093752 16 | Task with 45 minute duration • P4 • id=8485093753. 17 | Next: 18 | - Use get-overview to see your updated project organization" 19 | `; 20 | 21 | exports[`add-tasks tool adding multiple tasks should handle tasks with section and parent IDs 1`] = ` 22 | "Added 1 task to projects. 23 | Tasks: 24 | Subtask content • P2 • id=8485093750. 25 | Next: 26 | - Use get-overview to see your updated project organization" 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 | Next: 34 | - Use get-overview to see your updated project organization" 35 | `; 36 | 37 | exports[`add-tasks tool next steps logic should suggest overview tool when no hasToday context 1`] = ` 38 | "Added 1 task to projects. 39 | Tasks: 40 | Regular task • P4 • id=8485093756. 41 | Next: 42 | - Use get-overview to see your updated project organization" 43 | `; 44 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/complete-tasks.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 10 | - Use find-tasks-by-date('today') to tackle remaining overdue items." 11 | `; 12 | 13 | exports[`complete-tasks tool completing multiple tasks should complete single task 1`] = ` 14 | "Completed tasks: 1/1 successful. 15 | Completed: 16 | 8485093748. 17 | Next: 18 | - Use find-tasks-by-date('today') to tackle remaining overdue items." 19 | `; 20 | 21 | exports[`complete-tasks tool completing multiple tasks should continue processing remaining tasks after failures 1`] = ` 22 | "Completed tasks: 2/5 successful. 23 | Completed: 24 | task-3 25 | task-5. 26 | Failed (3): 27 | task-1 (Error: Task already completed) 28 | task-2 (Error: Task not found) 29 | task-4 (Error: Permission denied). 30 | Next: 31 | - Review failed completions and retry if needed." 32 | `; 33 | 34 | exports[`complete-tasks tool completing multiple tasks should handle all tasks failing 1`] = ` 35 | "Completed tasks: 0/2 successful. 36 | Failed (2): 37 | task-1 (Error: API Error: Network timeout) 38 | task-2 (Error: API Error: Network timeout). 39 | Next: 40 | - Check task IDs and permissions, then retry." 41 | `; 42 | 43 | exports[`complete-tasks tool completing multiple tasks should handle different types of API errors 1`] = ` 44 | "Completed tasks: 0/4 successful. 45 | Failed (4): 46 | not-found (Error: Task not found) 47 | already-done (Error: Task already completed) 48 | no-permission (Error: Permission denied), +1 more. 49 | Next: 50 | - Check task IDs and permissions, then retry." 51 | `; 52 | 53 | exports[`complete-tasks tool completing multiple tasks should handle partial failures gracefully 1`] = ` 54 | "Completed tasks: 2/3 successful. 55 | Completed: 56 | task-1 57 | task-3. 58 | Failed (1): 59 | task-2 (Error: Task not found). 60 | Next: 61 | - Review failed completions and retry if needed." 62 | `; 63 | 64 | exports[`complete-tasks tool edge cases should handle empty task completion (minimum one task required by schema) 1`] = ` 65 | "Completed tasks: 1/1 successful. 66 | Completed: 67 | single-task. 68 | Next: 69 | - Use find-tasks-by-date('today') to tackle remaining overdue items." 70 | `; 71 | 72 | exports[`complete-tasks tool edge cases should handle tasks with special ID formats 1`] = ` 73 | "Completed tasks: 3/3 successful. 74 | Completed: 75 | proj_123_task_456 76 | task-with-dashes 77 | 1234567890. 78 | Next: 79 | - Use find-tasks-by-date('today') to tackle remaining overdue items." 80 | `; 81 | 82 | exports[`complete-tasks tool error message truncation should not show truncation message for exactly 3 errors 1`] = ` 83 | "Completed tasks: 0/3 successful. 84 | Failed (3): 85 | task-1 (Error: Error 1) 86 | task-2 (Error: Error 2) 87 | task-3 (Error: Error 3). 88 | Next: 89 | - Check task IDs and permissions, then retry." 90 | `; 91 | 92 | exports[`complete-tasks tool error message truncation should truncate failure messages after 3 errors 1`] = ` 93 | "Completed tasks: 0/5 successful. 94 | Failed (5): 95 | task-1 (Error: Error 1) 96 | task-2 (Error: Error 2) 97 | task-3 (Error: Error 3), +2 more. 98 | Next: 99 | - Check task IDs and permissions, then retry." 100 | `; 101 | 102 | exports[`complete-tasks tool mixed success and failure scenarios should handle realistic mixed scenario 1`] = ` 103 | "Completed tasks: 3/5 successful. 104 | Completed: 105 | 8485093748 106 | 8485093749 107 | 8485093751. 108 | Failed (2): 109 | 8485093750 (Error: Task already completed) 110 | 8485093752 (Error: Task not found). 111 | Next: 112 | - Review failed completions and retry if needed." 113 | `; 114 | 115 | exports[`complete-tasks tool next steps logic validation should suggest checking IDs when all tasks fail 1`] = ` 116 | "Completed tasks: 0/2 successful. 117 | Failed (2): 118 | bad-id-1 (Error: Task not found) 119 | bad-id-2 (Error: Task not found). 120 | Next: 121 | - Check task IDs and permissions, then retry." 122 | `; 123 | 124 | exports[`complete-tasks tool next steps logic validation should suggest overdue tasks when all tasks complete successfully 1`] = ` 125 | "Completed tasks: 2/2 successful. 126 | Completed: 127 | task-1 128 | task-2. 129 | Next: 130 | - Use find-tasks-by-date('today') to tackle remaining overdue items." 131 | `; 132 | 133 | exports[`complete-tasks tool next steps logic validation should suggest reviewing failures when mixed results 1`] = ` 134 | "Completed tasks: 1/2 successful. 135 | Completed: 136 | task-1. 137 | Failed (1): 138 | task-2 (Error: Task not found). 139 | Next: 140 | - Review failed completions and retry if needed." 141 | `; 142 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/delete-object.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`delete-object tool deleting projects should delete a project by ID 1`] = ` 4 | "Deleted project: id=6cfCcrrCFg2xP94Q 5 | Next: 6 | - Use find-projects to see remaining projects 7 | - Note: All tasks and sections in this project were also deleted 8 | - Use get-overview to review your updated project structure" 9 | `; 10 | 11 | exports[`delete-object tool deleting sections should delete a section by ID 1`] = ` 12 | "Deleted section: id=section-123 13 | Next: 14 | - Use find-sections to see remaining sections in the project 15 | - Note: Tasks in this section were also deleted 16 | - Use find-tasks with projectId to see unorganized tasks" 17 | `; 18 | 19 | exports[`delete-object tool deleting tasks should delete a task by ID 1`] = ` 20 | "Deleted task: id=8485093748 21 | Next: 22 | - Use find-tasks-by-date to see remaining tasks for today 23 | - Use get-overview to check if this affects any dependent tasks 24 | - Note: Any subtasks of this task were also deleted" 25 | `; 26 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-comments.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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`] = ` 6 | "Found 1 comment for project project456 7 | Next: 8 | - Use add-comments with projectId=project456 to add new comment 9 | - Use find-comments with commentId to view specific comment details" 10 | `; 11 | 12 | exports[`find-comments tool finding comments by task should find comments for a task 1`] = ` 13 | "Found 2 comments for task task123 14 | Next: 15 | - Use add-comments with taskId=task123 to add new comment 16 | - Use find-comments with commentId to view specific comment details" 17 | `; 18 | 19 | exports[`find-comments tool finding comments by task should handle pagination 1`] = ` 20 | "Found 1 comment for task task123 • More available 21 | Next: 22 | - Use add-comments with taskId=task123 to add new comment 23 | - Use find-comments with commentId to view specific comment details 24 | - Use find-comments with cursor="next_page_token" to get more results" 25 | `; 26 | 27 | exports[`find-comments tool finding single comment should find comment by ID 1`] = ` 28 | "Found comment • id=comment789 29 | Next: 30 | - Use update-comments with id=comment789 to edit content 31 | - Use delete-object with type=comment id=comment789 to remove 32 | - Use find-comments with taskId=task123 to see all task comments" 33 | `; 34 | 35 | exports[`find-comments tool finding single comment should handle comment with attachment 1`] = ` 36 | "Found comment • Has attachment: document.pdf • id=comment789 37 | Next: 38 | - Use update-comments with id=comment789 to edit content 39 | - Use delete-object with type=comment id=comment789 to remove 40 | - Use find-comments with taskId=task123 to see all task comments" 41 | `; 42 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-completed-tasks.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 9 | - Use find-tasks-by-date for active tasks or get-overview for current productivity." 10 | `; 11 | 12 | exports[`find-completed-tasks tool getting completed tasks by completion date (default) should handle explicit completion date query 1`] = ` 13 | "Completed tasks (by completed date): 0 (limit 100), more available. 14 | Filter: completed date: 2025-08-01 to 2025-08-31; project: specific-project-id. 15 | No results. No tasks completed in this date range; Try expanding the date range; Try removing project/section/parent filters. 16 | Next: 17 | - Pass cursor 'next-cursor' to fetch more results." 18 | `; 19 | 20 | exports[`find-completed-tasks tool getting completed tasks by due date should get completed tasks by due date 1`] = ` 21 | "Completed tasks (by due date): 1 (limit 50). 22 | Filter: due date: 2025-08-10 to 2025-08-20. 23 | Preview: 24 | Task completed by due date • due 2025-08-15 • P2 • id=8485093750 25 | Next: 26 | - Use find-tasks-by-date for active tasks or get-overview for current productivity. 27 | - Recurring tasks will automatically create new instances." 28 | `; 29 | 30 | exports[`find-completed-tasks tool label filtering should combine other filters with label filters 1`] = ` 31 | "Completed tasks (by due date): 1 (limit 25). 32 | Filter: due date: 2025-08-01 to 2025-08-31; project: test-project-id; section: test-section-id; labels: @important. 33 | Preview: 34 | Important completed task • P4 • id=8485093748 35 | Next: 36 | - Use find-tasks-by-date for active tasks or get-overview for current productivity." 37 | `; 38 | 39 | exports[`find-completed-tasks tool label filtering should filter completed tasks by labels: multiple labels with AND operator 1`] = ` 40 | "Completed tasks (by due date): 1 (limit 50). 41 | Filter: due date: 2025-08-01 to 2025-08-31; labels: @work & @urgent. 42 | Preview: 43 | Completed task with label • P4 • id=8485093748 44 | Next: 45 | - Use find-tasks-by-date for active tasks or get-overview for current productivity." 46 | `; 47 | 48 | exports[`find-completed-tasks tool label filtering should filter completed tasks by labels: multiple labels with OR operator 1`] = ` 49 | "Completed tasks (by completed date): 1 (limit 25). 50 | Filter: completed date: 2025-08-10 to 2025-08-20; labels: @personal | @shopping. 51 | Preview: 52 | Completed task with label • P4 • id=8485093748 53 | Next: 54 | - Use find-tasks-by-date for active tasks or get-overview for current productivity." 55 | `; 56 | 57 | exports[`find-completed-tasks tool label filtering should filter completed tasks by labels: single label with OR operator 1`] = ` 58 | "Completed tasks (by completed date): 1 (limit 50). 59 | Filter: completed date: 2025-08-01 to 2025-08-31; labels: @work. 60 | Preview: 61 | Completed task with label • P4 • id=8485093748 62 | Next: 63 | - Use find-tasks-by-date for active tasks or get-overview for current productivity." 64 | `; 65 | 66 | exports[`find-completed-tasks tool timezone handling should convert user timezone to UTC correctly (Europe/Madrid) 1`] = ` 67 | "Completed tasks (by completed date): 1 (limit 50). 68 | Filter: completed date: 2025-10-11 to 2025-10-11. 69 | Preview: 70 | Task completed in Madrid timezone • P4 • id=8485093750 71 | Next: 72 | - Use find-tasks-by-date for active tasks or get-overview for current productivity." 73 | `; 74 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-projects.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 8 | - Use find-tasks with projectId to see tasks in specific projects. 9 | - Pass cursor 'next-page-cursor' to fetch more results." 10 | `; 11 | 12 | exports[`find-projects tool listing all projects should list all projects when no search parameter is provided 1`] = ` 13 | "Projects: 3 (limit 50). 14 | Preview: 15 | Inbox • Inbox • id=inbox-project-id 16 | test-abc123def456-project • id=6cfCcrrCFg2xP94Q 17 | Work Project • ⭐ • Shared • board • id=work-project-id 18 | Next: 19 | - Use find-tasks with projectId to see tasks in specific projects. 20 | - Favorite projects appear first in most Todoist views." 21 | `; 22 | 23 | exports[`find-projects tool searching projects should filter projects by search term (case insensitive) 1`] = ` 24 | "Projects matching "work": 2 (limit 50). 25 | Filter: search: "work". 26 | Preview: 27 | Work Project • id=work-project-id 28 | Hobby Work • id=hobby-project-id 29 | Next: 30 | - Use find-tasks with projectId to see tasks in specific projects." 31 | `; 32 | 33 | exports[`find-projects tool searching projects should handle search with case insensitive matching 1`] = ` 34 | "Projects matching "IMPORTANT": 1 (limit 50). 35 | Filter: search: "IMPORTANT". 36 | Preview: 37 | Important Project • id=6cfCcrrCFg2xP94Q 38 | Next: 39 | - Use find-tasks with projectId to see tasks in specific projects." 40 | `; 41 | 42 | exports[`find-projects tool searching projects should handle search with no matches 1`] = ` 43 | "Projects matching "nonexistent": 0 (limit 50). 44 | Filter: search: "nonexistent". 45 | No results. Try broader search terms; Check spelling; Remove search to see all projects." 46 | `; 47 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-sections.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 16 | - Use find-tasks with sectionId to see tasks in specific sections 17 | - Use update-sections to modify section names" 18 | `; 19 | 20 | exports[`find-sections tool searching sections by name should filter sections by search term (case insensitive) 1`] = ` 21 | "Sections in project 6cfCcrrCFg2xP94Q matching "progress": 2. 22 | Preview: 23 | In Progress • id=section-456 24 | Progress Review • id=section-999 25 | Next: 26 | - Use find-tasks with sectionId to see tasks in specific sections 27 | - Use update-sections to modify section names 28 | - Remove search parameter to see all sections in this project" 29 | `; 30 | 31 | exports[`find-sections tool searching sections by name should handle case sensitive search correctly 1`] = ` 32 | "Sections in project 6cfCcrrCFg2xP94Q matching "IMPORTANT": 1. 33 | Preview: 34 | Important Tasks • id=section-123 35 | Next: 36 | - Use find-tasks with sectionId=section-123 to see tasks 37 | - Use add-sections to create additional sections for organization 38 | - Remove search parameter to see all sections in this project" 39 | `; 40 | 41 | exports[`find-sections tool searching sections by name should handle exact matches 1`] = ` 42 | "Sections in project 6cfCcrrCFg2xP94Q matching "done": 2. 43 | Preview: 44 | Done • id=section-123 45 | Done Soon • id=section-456 46 | Next: 47 | - Use find-tasks with sectionId to see tasks in specific sections 48 | - Use update-sections to modify section names 49 | - Remove search parameter to see all sections in this project" 50 | `; 51 | 52 | exports[`find-sections tool searching sections by name should handle partial matches correctly 1`] = ` 53 | "Sections in project 6cfCcrrCFg2xP94Q matching "task": 2. 54 | Preview: 55 | Development Tasks • id=section-123 56 | Testing Tasks • id=section-456 57 | Next: 58 | - Use find-tasks with sectionId to see tasks in specific sections 59 | - Use update-sections to modify section names 60 | - Remove search parameter to see all sections in this project" 61 | `; 62 | 63 | exports[`find-sections tool searching sections by name should handle search with no matches 1`] = ` 64 | "Sections in project 6cfCcrrCFg2xP94Q matching "nonexistent": 0. 65 | No results. Try broader search terms; Check spelling; Remove search to see all sections." 66 | `; 67 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-tasks-by-date.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 15 | - Use update-tasks to modify priorities or due dates 16 | - Use complete-tasks to mark finished tasks 17 | - Focus on overdue items first to get back on track" 18 | `; 19 | 20 | exports[`find-tasks-by-date tool listing tasks by date range only returns tasks for the startDate when daysCount is 1 1`] = ` 21 | "Tasks for 2025-08-20: 1 (limit 50). 22 | Filter: 2025-08-20. 23 | Preview: 24 | Task for specific date • due 2025-08-20 • P4 • id=8485093748 25 | Next: 26 | - Use update-tasks to modify priorities or due dates 27 | - Use complete-tasks to mark finished tasks 28 | - Focus on overdue items first to get back on track" 29 | `; 30 | 31 | exports[`find-tasks-by-date tool listing tasks by date range should get tasks for today when startDate is "today" (includes overdue) 1`] = ` 32 | "Today's tasks + overdue: 1 (limit 50). 33 | Filter: today + overdue tasks + 6 more days. 34 | Preview: 35 | Today task • due 2025-08-15 • P4 • id=8485093748 36 | Next: 37 | - Use update-tasks to modify priorities or due dates 38 | - Use complete-tasks to mark finished tasks 39 | - Focus on overdue items first to get back on track" 40 | `; 41 | 42 | exports[`find-tasks-by-date tool listing tasks by date range should handle multiple days with pagination 1`] = ` 43 | "Tasks for 2025-08-20: 2 (limit 20), more available. 44 | Filter: 2025-08-20 to 2025-08-23. 45 | Preview: 46 | Multi-day task 1 • due 2025-08-20 • P4 • id=8485093749 47 | Multi-day task 2 • due 2025-08-21 • P4 • id=8485093750 48 | Next: 49 | - Use update-tasks to modify priorities or due dates 50 | - Use complete-tasks to mark finished tasks 51 | - Focus on overdue items first to get back on track 52 | - Pass cursor 'next-page-cursor' to fetch more results." 53 | `; 54 | 55 | exports[`find-tasks-by-date tool listing tasks by date range should handle specific date 1`] = ` 56 | "Tasks for 2025-08-20: 1 (limit 50). 57 | Filter: 2025-08-20 to 2025-08-27. 58 | Preview: 59 | Specific date task • due 2025-08-20 • P4 • id=8485093748 60 | Next: 61 | - Use update-tasks to modify priorities or due dates 62 | - Use complete-tasks to mark finished tasks 63 | - Focus on overdue items first to get back on track" 64 | `; 65 | 66 | exports[`find-tasks-by-date tool next steps logic should provide helpful suggestions for empty date range results 1`] = ` 67 | "Tasks for 2025-08-20: 0 (limit 10). 68 | Filter: 2025-08-20. 69 | No results. Expand date range with larger 'daysCount'; Check today's tasks with startDate='today'." 70 | `; 71 | 72 | exports[`find-tasks-by-date tool next steps logic should provide helpful suggestions for empty today results 1`] = ` 73 | "Today's tasks + overdue: 0 (limit 10). 74 | Filter: today + overdue tasks. 75 | No results. Great job! No tasks for today or overdue." 76 | `; 77 | 78 | exports[`find-tasks-by-date tool next steps logic should suggest appropriate actions when hasOverdue is true 1`] = ` 79 | "Tasks for 2025-08-15: 1 (limit 10). 80 | Filter: 2025-08-15. 81 | Preview: 82 | Overdue task from list • due 2025-08-10 • P4 • id=8485093748 83 | Next: 84 | - Use update-tasks to modify priorities or due dates 85 | - Use complete-tasks to mark finished tasks 86 | - Focus on overdue items first to get back on track" 87 | `; 88 | 89 | exports[`find-tasks-by-date tool next steps logic should suggest today-focused actions when startDate is today 1`] = ` 90 | "Today's tasks + overdue: 1 (limit 10). 91 | Filter: today + overdue tasks. 92 | Preview: 93 | Today's task • due 2025-08-15 • P4 • id=8485093748 94 | Next: 95 | - Use update-tasks to modify priorities or due dates 96 | - Use complete-tasks to mark finished tasks 97 | - Focus on overdue items first to get back on track" 98 | `; 99 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/find-tasks.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 9 | - Use update-tasks to modify priorities or due dates 10 | - Use complete-tasks to mark finished tasks" 11 | `; 12 | 13 | exports[`find-tasks tool container filtering should find tasks in project 1`] = ` 14 | "Tasks in project: 1 (limit 10). 15 | Filter: in project 6cfCcrrCFg2xP94Q. 16 | Preview: 17 | Project task • P4 • id=8485093748 18 | Next: 19 | - Use update-tasks to modify priorities or due dates 20 | - Use complete-tasks to mark finished tasks" 21 | `; 22 | 23 | exports[`find-tasks tool container filtering should find tasks in section 1`] = ` 24 | "Tasks in section: 1 (limit 10). 25 | Filter: in section section-123. 26 | Preview: 27 | Section task • P4 • id=8485093748 28 | Next: 29 | - Use update-tasks to modify priorities or due dates 30 | - Use complete-tasks to mark finished tasks" 31 | `; 32 | 33 | exports[`find-tasks tool next steps logic should provide different next steps for regular tasks 1`] = ` 34 | "Search results for "future tasks": 1 (limit 10). 35 | Filter: matching "future tasks". 36 | Preview: 37 | Regular future task • due 2025-08-25 • P4 • id=8485093748 38 | Next: 39 | - Use update-tasks to modify priorities or due dates 40 | - Use complete-tasks to mark finished tasks 41 | - Focus on overdue items first to get back on track" 42 | `; 43 | 44 | exports[`find-tasks tool next steps logic should provide helpful suggestions for empty search results 1`] = ` 45 | "Search results for "nonexistent": 0 (limit 10). 46 | Filter: matching "nonexistent". 47 | No results. Try broader search terms; Verify spelling and try partial words; Check completed tasks with find-completed-tasks." 48 | `; 49 | 50 | exports[`find-tasks tool next steps logic should suggest different actions when hasOverdue is true 1`] = ` 51 | "Search results for "overdue tasks": 1 (limit 10). 52 | Filter: matching "overdue tasks". 53 | Preview: 54 | Overdue search result • due 2025-08-10 • P4 • id=8485093748 55 | Next: 56 | - Use update-tasks to modify priorities or due dates 57 | - Use complete-tasks to mark finished tasks 58 | - Focus on overdue items first to get back on track" 59 | `; 60 | 61 | exports[`find-tasks tool next steps logic should suggest today tasks when hasToday is true 1`] = ` 62 | "Search results for "today tasks": 1 (limit 10). 63 | Filter: matching "today tasks". 64 | Preview: 65 | Task due today • due 2025-08-17 • P4 • id=8485093748 66 | Next: 67 | - Use update-tasks to modify priorities or due dates 68 | - Use complete-tasks to mark finished tasks 69 | - Focus on overdue items first to get back on track" 70 | `; 71 | 72 | exports[`find-tasks tool searching tasks should handle custom limit 1`] = ` 73 | "Search results for "project update": 1 (limit 5). 74 | Filter: matching "project update". 75 | Preview: 76 | Test result • P4 • id=8485093748 77 | Next: 78 | - Use update-tasks to modify priorities or due dates 79 | - Use complete-tasks to mark finished tasks" 80 | `; 81 | 82 | exports[`find-tasks tool searching tasks should handle pagination cursor 1`] = ` 83 | "Search results for "follow up": 1 (limit 20). 84 | Filter: matching "follow up". 85 | Preview: 86 | Test result • P4 • id=8485093748 87 | Next: 88 | - Use update-tasks to modify priorities or due dates 89 | - Use complete-tasks to mark finished tasks" 90 | `; 91 | 92 | exports[`find-tasks tool searching tasks should handle search with empty results 1`] = ` 93 | "Search results for "nonexistent keyword": 0 (limit 10). 94 | Filter: matching "nonexistent keyword". 95 | No results. Try broader search terms; Verify spelling and try partial words; Check completed tasks with find-completed-tasks." 96 | `; 97 | 98 | exports[`find-tasks tool searching tasks should handle search with special characters 1`] = ` 99 | "Search results for "@work #urgent "exact phrase"": 0 (limit 10). 100 | Filter: matching "@work #urgent "exact phrase"". 101 | No results. Try broader search terms; Verify spelling and try partial words; Check completed tasks with find-completed-tasks." 102 | `; 103 | 104 | exports[`find-tasks tool searching tasks should search tasks and return results 1`] = ` 105 | "Search results for "important meeting": 2 (limit 10), more available. 106 | Filter: matching "important meeting". 107 | Preview: 108 | Task containing search term • P4 • id=8485093748 109 | Another matching task • P3 • id=8485093749 110 | Next: 111 | - Use update-tasks to modify priorities or due dates 112 | - Use complete-tasks to mark finished tasks 113 | - Pass cursor 'cursor-for-next-page' to fetch more results." 114 | `; 115 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/get-overview.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`update-comments tool bulk operations should update multiple comments from different entities (task + project) 1`] = ` 4 | "Updated 1 task comment and 1 project comment 5 | Next: 6 | - Use find-comments to view comments by task or project 7 | - Use delete-object with type=comment to remove comments" 8 | `; 9 | 10 | exports[`update-comments tool bulk operations should update multiple comments from different tasks 1`] = ` 11 | "Updated 2 task comments 12 | Next: 13 | - Use find-comments to view comments by task or project 14 | - Use delete-object with type=comment to remove comments" 15 | `; 16 | 17 | exports[`update-comments tool bulk operations should update multiple comments from the same project 1`] = ` 18 | "Updated 2 project comments 19 | Next: 20 | - Use find-comments to view comments by task or project 21 | - Use delete-object with type=comment to remove comments" 22 | `; 23 | 24 | exports[`update-comments tool bulk operations should update multiple comments from the same task 1`] = ` 25 | "Updated 2 task comments 26 | Next: 27 | - Use find-comments to view comments by task or project 28 | - Use delete-object with type=comment to remove comments" 29 | `; 30 | 31 | exports[`update-comments tool should handle project comment 1`] = ` 32 | "Updated 1 project comment 33 | Next: 34 | - Use find-comments with projectId=project789 to see all project comments 35 | - Use delete-object with type=comment id=98767 to remove comment" 36 | `; 37 | 38 | exports[`update-comments tool should update comment content 1`] = ` 39 | "Updated 1 task comment 40 | Next: 41 | - Use find-comments with taskId=task456 to see all task comments 42 | - Use delete-object with type=comment id=98765 to remove comment" 43 | `; 44 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/update-projects.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 7 | - Use get-overview with projectId=existing-project-123 to see project structure 8 | - Use find-tasks with projectId=existing-project-123 to review existing tasks" 9 | `; 10 | 11 | exports[`update-projects tool updating a single project should update project with isFavorite and viewStyle options 1`] = ` 12 | "Updated 1 project: 13 | • Updated Favorite Project (id=project-123) 14 | Next: 15 | - Use get-overview with projectId=project-123 to see project structure 16 | - Use find-tasks with projectId=project-123 to review existing tasks" 17 | `; 18 | 19 | exports[`update-projects tool updating multiple projects should skip projects with no updates and report correctly 1`] = ` 20 | "Updated 1 project (1 skipped - no changes): 21 | • Updated Project (id=project-1) 22 | Next: 23 | - Use get-overview with projectId=project-1 to see project structure 24 | - Use find-tasks with projectId=project-1 to review existing tasks" 25 | `; 26 | 27 | exports[`update-projects tool updating multiple projects should update multiple projects and return mapped results 1`] = ` 28 | "Updated 3 projects: 29 | • Updated First Project (id=project-1) 30 | • Updated Second Project (id=project-2) 31 | • Updated Third Project (id=project-3) 32 | Next: 33 | - Use find-projects to see all projects with updated names 34 | - Use get-overview to see updated project hierarchy" 35 | `; 36 | -------------------------------------------------------------------------------- /src/tools/__tests__/__snapshots__/update-sections.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 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 | Next: 7 | - Use find-tasks with sectionId=existing-section-123 to see existing tasks 8 | - Use get-overview with projectId=6cfCcrrCFg2xP94Q to see project structure 9 | - Consider updating task descriptions if section purpose changed" 10 | `; 11 | 12 | exports[`update-sections tool updating multiple sections should handle sections from the same project 1`] = ` 13 | "Updated 2 sections: 14 | • Backlog (id=section-1, projectId=same-project) 15 | • Done (id=section-2, projectId=same-project) 16 | Next: 17 | - Use find-sections to see all sections with updated names 18 | - Use get-overview with projectId=same-project to see updated project structure 19 | - Consider updating task descriptions if section purposes changed" 20 | `; 21 | 22 | exports[`update-sections tool updating multiple sections should update multiple sections and return mapped results 1`] = ` 23 | "Updated 3 sections: 24 | • Updated First Section (id=section-1, projectId=project-1) 25 | • Updated Second Section (id=section-2, projectId=project-1) 26 | • Updated Third Section (id=section-3, projectId=project-2) 27 | Next: 28 | - Use find-sections to see all sections with updated names 29 | - Use get-overview to see updated project structures 30 | - Consider updating task descriptions if section purposes changed" 31 | `; 32 | -------------------------------------------------------------------------------- /src/tools/__tests__/delete-object.test.ts: -------------------------------------------------------------------------------- 1 | import type { TodoistApi } from '@doist/todoist-api-typescript' 2 | import { jest } from '@jest/globals' 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: jest.fn(), 10 | deleteSection: jest.fn(), 11 | deleteTask: jest.fn(), 12 | } as unknown as jest.Mocked 13 | 14 | const { FIND_PROJECTS, FIND_TASKS_BY_DATE, DELETE_OBJECT } = ToolNames 15 | 16 | describe(`${DELETE_OBJECT} tool`, () => { 17 | beforeEach(() => { 18 | jest.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(textContent).toContain(`Use ${FIND_PROJECTS} to see remaining projects`) 38 | expect(result.structuredContent).toEqual({ 39 | deletedEntity: { 40 | type: 'project', 41 | id: '6cfCcrrCFg2xP94Q', 42 | }, 43 | success: true, 44 | }) 45 | }) 46 | 47 | it('should propagate project deletion errors', async () => { 48 | const apiError = new Error('API Error: Cannot delete project with tasks') 49 | mockTodoistApi.deleteProject.mockRejectedValue(apiError) 50 | 51 | await expect( 52 | deleteObject.execute({ type: 'project', id: 'project-with-tasks' }, mockTodoistApi), 53 | ).rejects.toThrow('API Error: Cannot delete project with tasks') 54 | }) 55 | }) 56 | 57 | describe('deleting sections', () => { 58 | it('should delete a section by ID', async () => { 59 | mockTodoistApi.deleteSection.mockResolvedValue(true) 60 | 61 | const result = await deleteObject.execute( 62 | { type: 'section', id: 'section-123' }, 63 | mockTodoistApi, 64 | ) 65 | 66 | expect(mockTodoistApi.deleteSection).toHaveBeenCalledWith('section-123') 67 | expect(mockTodoistApi.deleteProject).not.toHaveBeenCalled() 68 | expect(mockTodoistApi.deleteTask).not.toHaveBeenCalled() 69 | 70 | const textContent = extractTextContent(result) 71 | expect(textContent).toMatchSnapshot() 72 | expect(textContent).toContain('Deleted section: id=section-123') 73 | expect(textContent).toContain( 74 | `Use ${ToolNames.FIND_SECTIONS} to see remaining sections`, 75 | ) 76 | expect(result.structuredContent).toEqual({ 77 | deletedEntity: { type: 'section', id: 'section-123' }, 78 | success: true, 79 | }) 80 | }) 81 | 82 | it('should propagate section deletion errors', async () => { 83 | const apiError = new Error('API Error: Section not found') 84 | mockTodoistApi.deleteSection.mockRejectedValue(apiError) 85 | 86 | await expect( 87 | deleteObject.execute( 88 | { type: 'section', id: 'non-existent-section' }, 89 | mockTodoistApi, 90 | ), 91 | ).rejects.toThrow('API Error: Section not found') 92 | }) 93 | }) 94 | 95 | describe('deleting tasks', () => { 96 | it('should delete a task by ID', async () => { 97 | mockTodoistApi.deleteTask.mockResolvedValue(true) 98 | 99 | const result = await deleteObject.execute( 100 | { type: 'task', id: '8485093748' }, 101 | mockTodoistApi, 102 | ) 103 | 104 | expect(mockTodoistApi.deleteTask).toHaveBeenCalledWith('8485093748') 105 | expect(mockTodoistApi.deleteProject).not.toHaveBeenCalled() 106 | expect(mockTodoistApi.deleteSection).not.toHaveBeenCalled() 107 | 108 | const textContent = extractTextContent(result) 109 | expect(textContent).toMatchSnapshot() 110 | expect(textContent).toContain('Deleted task: id=8485093748') 111 | expect(textContent).toContain(`Use ${FIND_TASKS_BY_DATE} to see remaining tasks`) 112 | expect(result.structuredContent).toEqual({ 113 | deletedEntity: { type: 'task', id: '8485093748' }, 114 | success: true, 115 | }) 116 | }) 117 | 118 | it('should propagate task deletion errors', async () => { 119 | const apiError = new Error('API Error: Task not found') 120 | mockTodoistApi.deleteTask.mockRejectedValue(apiError) 121 | 122 | await expect( 123 | deleteObject.execute({ type: 'task', id: 'non-existent-task' }, mockTodoistApi), 124 | ).rejects.toThrow('API Error: Task not found') 125 | }) 126 | 127 | it('should handle permission errors', async () => { 128 | const apiError = new Error('API Error: Insufficient permissions to delete task') 129 | mockTodoistApi.deleteTask.mockRejectedValue(apiError) 130 | 131 | await expect( 132 | deleteObject.execute({ type: 'task', id: 'restricted-task' }, mockTodoistApi), 133 | ).rejects.toThrow('API Error: Insufficient permissions to delete task') 134 | }) 135 | }) 136 | 137 | describe('type validation', () => { 138 | it('should handle all supported entity types', async () => { 139 | mockTodoistApi.deleteProject.mockResolvedValue(true) 140 | mockTodoistApi.deleteSection.mockResolvedValue(true) 141 | mockTodoistApi.deleteTask.mockResolvedValue(true) 142 | 143 | // Delete project 144 | await deleteObject.execute({ type: 'project', id: 'proj-1' }, mockTodoistApi) 145 | expect(mockTodoistApi.deleteProject).toHaveBeenCalledWith('proj-1') 146 | 147 | // Delete section 148 | await deleteObject.execute({ type: 'section', id: 'sect-1' }, mockTodoistApi) 149 | expect(mockTodoistApi.deleteSection).toHaveBeenCalledWith('sect-1') 150 | 151 | // Delete task 152 | await deleteObject.execute({ type: 'task', id: 'task-1' }, mockTodoistApi) 153 | expect(mockTodoistApi.deleteTask).toHaveBeenCalledWith('task-1') 154 | 155 | // Verify each API method was called exactly once 156 | expect(mockTodoistApi.deleteProject).toHaveBeenCalledTimes(1) 157 | expect(mockTodoistApi.deleteSection).toHaveBeenCalledTimes(1) 158 | expect(mockTodoistApi.deleteTask).toHaveBeenCalledTimes(1) 159 | }) 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /src/tools/__tests__/find-projects.test.ts: -------------------------------------------------------------------------------- 1 | import type { TodoistApi } from '@doist/todoist-api-typescript' 2 | import { jest } from '@jest/globals' 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: jest.fn(), 17 | } as unknown as jest.Mocked 18 | 19 | const { FIND_PROJECTS } = ToolNames 20 | 21 | describe(`${FIND_PROJECTS} tool`, () => { 22 | beforeEach(() => { 23 | jest.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 { jest } from '@jest/globals' 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: jest.fn(), 14 | } as unknown as jest.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 | jest.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 { formatNextSteps } from '../utils/response-builders.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const { FIND_COMMENTS, UPDATE_COMMENTS, DELETE_OBJECT } = ToolNames 9 | 10 | const CommentSchema = z.object({ 11 | taskId: z.string().optional().describe('The ID of the task to comment on.'), 12 | projectId: z.string().optional().describe('The ID of the project to comment on.'), 13 | content: z.string().min(1).describe('The content of the comment.'), 14 | }) 15 | 16 | const ArgsSchema = { 17 | comments: z.array(CommentSchema).min(1).describe('The array of comments to add.'), 18 | } 19 | 20 | const addComments = { 21 | name: ToolNames.ADD_COMMENTS, 22 | description: 23 | 'Add multiple comments to tasks or projects. Each comment must specify either taskId or projectId.', 24 | parameters: ArgsSchema, 25 | async execute(args, client) { 26 | const { comments } = args 27 | 28 | // Validate each comment 29 | for (const [index, comment] of comments.entries()) { 30 | if (!comment.taskId && !comment.projectId) { 31 | throw new Error( 32 | `Comment ${index + 1}: Either taskId or projectId must be provided.`, 33 | ) 34 | } 35 | if (comment.taskId && comment.projectId) { 36 | throw new Error( 37 | `Comment ${index + 1}: Cannot provide both taskId and projectId. Choose one.`, 38 | ) 39 | } 40 | } 41 | 42 | const addCommentPromises = comments.map( 43 | async ({ content, taskId, projectId }) => 44 | await client.addComment({ 45 | content, 46 | ...(taskId ? { taskId } : { projectId }), 47 | } as AddCommentArgs), 48 | ) 49 | 50 | const newComments = await Promise.all(addCommentPromises) 51 | const textContent = generateTextContent({ comments: newComments }) 52 | 53 | return getToolOutput({ 54 | textContent, 55 | structuredContent: { 56 | comments: newComments, 57 | totalCount: newComments.length, 58 | addedCommentIds: newComments.map((comment) => comment.id), 59 | }, 60 | }) 61 | }, 62 | } satisfies TodoistTool 63 | 64 | function generateTextContent({ comments }: { comments: Comment[] }): string { 65 | // Group comments by entity type and count 66 | const taskComments = comments.filter((c) => c.taskId).length 67 | const projectComments = comments.filter((c) => c.projectId).length 68 | 69 | // Generate summary text 70 | const parts: string[] = [] 71 | if (taskComments > 0) { 72 | const commentsLabel = taskComments > 1 ? 'comments' : 'comment' 73 | parts.push(`${taskComments} task ${commentsLabel}`) 74 | } 75 | if (projectComments > 0) { 76 | const commentsLabel = projectComments > 1 ? 'comments' : 'comment' 77 | parts.push(`${projectComments} project ${commentsLabel}`) 78 | } 79 | const summary = parts.length > 0 ? `Added ${parts.join(' and ')}` : 'No comments added' 80 | 81 | // Context-aware next steps 82 | const nextSteps: string[] = [] 83 | if (comments.length > 0) { 84 | if (comments.length === 1 && comments[0]) { 85 | const comment = comments[0] 86 | const targetId = comment.taskId || comment.projectId || '' 87 | const targetType = comment.taskId ? 'task' : 'project' 88 | nextSteps.push( 89 | `Use ${FIND_COMMENTS} with ${targetType}Id=${targetId} to see all comments`, 90 | ) 91 | nextSteps.push(`Use ${UPDATE_COMMENTS} with id=${comment.id} to edit content`) 92 | } else { 93 | nextSteps.push(`Use ${FIND_COMMENTS} to view comments by task or project`) 94 | nextSteps.push(`Use ${UPDATE_COMMENTS} to edit any comment content`) 95 | } 96 | nextSteps.push(`Use ${DELETE_OBJECT} with type=comment to remove comments`) 97 | } 98 | 99 | const next = formatNextSteps(nextSteps) 100 | return `${summary}\n${next}` 101 | } 102 | 103 | export { addComments } 104 | -------------------------------------------------------------------------------- /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 { formatNextSteps } from '../utils/response-builders.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const { ADD_SECTIONS, ADD_TASKS, FIND_PROJECTS, GET_OVERVIEW } = ToolNames 9 | 10 | const ProjectSchema = z.object({ 11 | name: z.string().min(1).describe('The name of the project.'), 12 | parentId: z 13 | .string() 14 | .optional() 15 | .describe('The ID of the parent project. If provided, creates this as a sub-project.'), 16 | isFavorite: z 17 | .boolean() 18 | .optional() 19 | .describe('Whether the project is a favorite. Defaults to false.'), 20 | viewStyle: z 21 | .enum(['list', 'board', 'calendar']) 22 | .optional() 23 | .describe('The project view style. Defaults to "list".'), 24 | }) 25 | 26 | const ArgsSchema = { 27 | projects: z.array(ProjectSchema).min(1).describe('The array of projects to add.'), 28 | } 29 | 30 | const addProjects = { 31 | name: ToolNames.ADD_PROJECTS, 32 | description: 'Add one or more new projects.', 33 | parameters: ArgsSchema, 34 | async execute({ projects }, client) { 35 | const newProjects = await Promise.all(projects.map((project) => client.addProject(project))) 36 | const textContent = generateTextContent({ projects: newProjects }) 37 | 38 | return getToolOutput({ 39 | textContent, 40 | structuredContent: { 41 | projects: newProjects, 42 | totalCount: newProjects.length, 43 | }, 44 | }) 45 | }, 46 | } satisfies TodoistTool 47 | 48 | function generateTextContent({ projects }: { projects: (PersonalProject | WorkspaceProject)[] }) { 49 | const count = projects.length 50 | const projectList = projects.map((project) => `• ${project.name} (id=${project.id})`).join('\n') 51 | 52 | const summary = `Added ${count} project${count === 1 ? '' : 's'}:\n${projectList}` 53 | 54 | // Context-aware next steps for new projects 55 | const nextSteps: string[] = [] 56 | 57 | if (count === 1) { 58 | const project = projects[0] 59 | if (project) { 60 | nextSteps.push(`Use ${ADD_SECTIONS} to organize new project with sections`) 61 | nextSteps.push(`Use ${ADD_TASKS} to add your first tasks to this project`) 62 | nextSteps.push( 63 | `Use ${GET_OVERVIEW} with projectId=${project.id} to see project structure`, 64 | ) 65 | } 66 | } else { 67 | nextSteps.push(`Use ${ADD_SECTIONS} to organize these projects with sections`) 68 | nextSteps.push(`Use ${ADD_TASKS} to add tasks to these projects`) 69 | nextSteps.push(`Use ${FIND_PROJECTS} to see all projects including the new ones`) 70 | nextSteps.push(`Use ${GET_OVERVIEW} to see updated project hierarchy`) 71 | } 72 | 73 | const next = formatNextSteps(nextSteps) 74 | return `${summary}\n${next}` 75 | } 76 | 77 | export { addProjects } 78 | -------------------------------------------------------------------------------- /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 { formatNextSteps } from '../utils/response-builders.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const { ADD_TASKS, FIND_TASKS, GET_OVERVIEW, FIND_SECTIONS } = ToolNames 9 | 10 | const SectionSchema = z.object({ 11 | name: z.string().min(1).describe('The name of the section.'), 12 | projectId: z.string().min(1).describe('The ID of the project to add the section to.'), 13 | }) 14 | 15 | const ArgsSchema = { 16 | sections: z.array(SectionSchema).min(1).describe('The array of sections to add.'), 17 | } 18 | 19 | const addSections = { 20 | name: ToolNames.ADD_SECTIONS, 21 | description: 'Add one or more new sections to projects.', 22 | parameters: ArgsSchema, 23 | async execute({ sections }, client) { 24 | const newSections = await Promise.all(sections.map((section) => client.addSection(section))) 25 | const textContent = generateTextContent({ sections: newSections }) 26 | 27 | return getToolOutput({ 28 | textContent, 29 | structuredContent: { 30 | sections: newSections, 31 | totalCount: newSections.length, 32 | }, 33 | }) 34 | }, 35 | } satisfies TodoistTool 36 | 37 | function generateTextContent({ sections }: { sections: Section[] }) { 38 | const count = sections.length 39 | const sectionList = sections 40 | .map((section) => `• ${section.name} (id=${section.id}, projectId=${section.projectId})`) 41 | .join('\n') 42 | 43 | const summary = `Added ${count} section${count === 1 ? '' : 's'}:\n${sectionList}` 44 | 45 | // Context-aware next steps for new sections 46 | const nextSteps: string[] = [] 47 | 48 | if (count === 1) { 49 | const section = sections[0] 50 | if (section) { 51 | nextSteps.push(`Use ${ADD_TASKS} with sectionId=${section.id} to add your first tasks`) 52 | nextSteps.push(`Use ${FIND_TASKS} with sectionId=${section.id} to verify setup`) 53 | nextSteps.push( 54 | `Use ${GET_OVERVIEW} with projectId=${section.projectId} to see project organization`, 55 | ) 56 | } 57 | } else { 58 | // Group sections by project for better guidance 59 | const projectIds = [...new Set(sections.map((s) => s.projectId))] 60 | 61 | nextSteps.push(`Use ${ADD_TASKS} to add tasks to these new sections`) 62 | 63 | if (projectIds.length === 1) { 64 | nextSteps.push( 65 | `Use ${GET_OVERVIEW} with projectId=${projectIds[0]} to see updated project structure`, 66 | ) 67 | nextSteps.push( 68 | `Use ${FIND_SECTIONS} with projectId=${projectIds[0]} to see all sections`, 69 | ) 70 | } else { 71 | nextSteps.push(`Use ${GET_OVERVIEW} to see updated project structures`) 72 | nextSteps.push(`Use ${FIND_SECTIONS} to see sections in specific projects`) 73 | } 74 | } 75 | 76 | const next = formatNextSteps(nextSteps) 77 | return `${summary}\n${next}` 78 | } 79 | 80 | export { addSections } 81 | -------------------------------------------------------------------------------- /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 { summarizeBatch } from '../utils/response-builders.js' 5 | import { ToolNames } from '../utils/tool-names.js' 6 | 7 | const ArgsSchema = { 8 | ids: z.array(z.string().min(1)).min(1).describe('The IDs of the tasks to complete.'), 9 | } 10 | 11 | const completeTasks = { 12 | name: ToolNames.COMPLETE_TASKS, 13 | description: 'Complete one or more tasks by their IDs.', 14 | parameters: ArgsSchema, 15 | async execute(args, client) { 16 | const completed: string[] = [] 17 | const failures: Array<{ item: string; error: string; code?: string }> = [] 18 | 19 | for (const id of args.ids) { 20 | try { 21 | await client.closeTask(id) 22 | completed.push(id) 23 | } catch (error) { 24 | const errorMessage = error instanceof Error ? error.message : 'Unknown error' 25 | failures.push({ 26 | item: id, 27 | error: errorMessage, 28 | }) 29 | } 30 | } 31 | 32 | const textContent = generateTextContent({ 33 | completed, 34 | failures, 35 | args, 36 | }) 37 | 38 | return getToolOutput({ 39 | textContent, 40 | structuredContent: { 41 | completed, 42 | failures, 43 | totalRequested: args.ids.length, 44 | successCount: completed.length, 45 | failureCount: failures.length, 46 | }, 47 | }) 48 | }, 49 | } satisfies TodoistTool 50 | 51 | function generateNextSteps(completed: number, failures: number): string[] { 52 | if (completed > 0) { 53 | const moveResult = 54 | failures === 0 55 | ? "Use find-tasks-by-date('today') to tackle remaining overdue items." 56 | : 'Review failed completions and retry if needed.' 57 | return [moveResult] 58 | } 59 | 60 | if (failures > 0) { 61 | return ['Check task IDs and permissions, then retry.'] 62 | } 63 | 64 | return ['No tasks were completed.'] 65 | } 66 | 67 | function generateTextContent({ 68 | completed, 69 | failures, 70 | args, 71 | }: { 72 | completed: string[] 73 | failures: Array<{ item: string; error: string; code?: string }> 74 | args: z.infer> 75 | }) { 76 | const nextSteps = generateNextSteps(completed.length, failures.length) 77 | return summarizeBatch({ 78 | action: 'Completed tasks', 79 | success: completed.length, 80 | total: args.ids.length, 81 | successItems: completed, 82 | failures, 83 | nextSteps, 84 | }) 85 | } 86 | 87 | export { completeTasks } 88 | -------------------------------------------------------------------------------- /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 { formatNextSteps } from '../utils/response-builders.js' 5 | import { ToolNames } from '../utils/tool-names.js' 6 | 7 | const { 8 | FIND_PROJECTS, 9 | GET_OVERVIEW, 10 | FIND_SECTIONS, 11 | FIND_TASKS, 12 | FIND_TASKS_BY_DATE, 13 | FIND_COMMENTS, 14 | } = ToolNames 15 | 16 | const ArgsSchema = { 17 | type: z 18 | .enum(['project', 'section', 'task', 'comment']) 19 | .describe('The type of entity to delete.'), 20 | id: z.string().min(1).describe('The ID of the entity to delete.'), 21 | } 22 | 23 | const deleteObject = { 24 | name: ToolNames.DELETE_OBJECT, 25 | description: 'Delete a project, section, task, or comment by its ID.', 26 | parameters: ArgsSchema, 27 | async execute(args, client) { 28 | switch (args.type) { 29 | case 'project': 30 | await client.deleteProject(args.id) 31 | break 32 | case 'section': 33 | await client.deleteSection(args.id) 34 | break 35 | case 'task': 36 | await client.deleteTask(args.id) 37 | break 38 | case 'comment': 39 | await client.deleteComment(args.id) 40 | break 41 | } 42 | 43 | const textContent = generateTextContent({ 44 | type: args.type, 45 | id: args.id, 46 | }) 47 | 48 | return getToolOutput({ 49 | textContent, 50 | structuredContent: { 51 | deletedEntity: { 52 | type: args.type, 53 | id: args.id, 54 | }, 55 | success: true, 56 | }, 57 | }) 58 | }, 59 | } satisfies TodoistTool 60 | 61 | function generateTextContent({ 62 | type, 63 | id, 64 | }: { 65 | type: 'project' | 'section' | 'task' | 'comment' 66 | id: string 67 | }): string { 68 | const summary = `Deleted ${type}: id=${id}` 69 | 70 | // Recovery-focused next steps based on what was deleted 71 | const nextSteps: string[] = [] 72 | 73 | switch (type) { 74 | case 'project': 75 | // Help user understand impact and navigate remaining work 76 | nextSteps.push(`Use ${FIND_PROJECTS} to see remaining projects`) 77 | nextSteps.push('Note: All tasks and sections in this project were also deleted') 78 | nextSteps.push(`Use ${GET_OVERVIEW} to review your updated project structure`) 79 | break 80 | 81 | case 'section': 82 | // Guide user to reorganize remaining sections and tasks 83 | nextSteps.push(`Use ${FIND_SECTIONS} to see remaining sections in the project`) 84 | nextSteps.push('Note: Tasks in this section were also deleted') 85 | nextSteps.push(`Use ${FIND_TASKS} with projectId to see unorganized tasks`) 86 | break 87 | 88 | case 'task': 89 | // Help user stay focused on remaining work 90 | nextSteps.push(`Use ${FIND_TASKS_BY_DATE} to see remaining tasks for today`) 91 | nextSteps.push(`Use ${GET_OVERVIEW} to check if this affects any dependent tasks`) 92 | nextSteps.push('Note: Any subtasks of this task were also deleted') 93 | break 94 | 95 | case 'comment': 96 | // Help user understand comment deletion impact 97 | nextSteps.push(`Use ${FIND_COMMENTS} to see remaining comments on the task/project`) 98 | nextSteps.push('Note: Comment attachments were also deleted') 99 | break 100 | } 101 | 102 | const next = formatNextSteps(nextSteps) 103 | return `${summary}\n${next}` 104 | } 105 | 106 | export { deleteObject } 107 | -------------------------------------------------------------------------------- /src/tools/fetch.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { getErrorOutput } from '../mcp-helpers.js' 3 | import type { TodoistTool } from '../todoist-tool.js' 4 | import { buildTodoistUrl, mapProject, mapTask } from '../tool-helpers.js' 5 | import { ToolNames } from '../utils/tool-names.js' 6 | 7 | const ArgsSchema = { 8 | id: z 9 | .string() 10 | .min(1) 11 | .describe( 12 | 'A unique identifier for the document in the format "task:{id}" or "project:{id}".', 13 | ), 14 | } 15 | 16 | type FetchResult = { 17 | id: string 18 | title: string 19 | text: string 20 | url: string 21 | metadata?: Record 22 | } 23 | 24 | type FetchToolOutput = { 25 | content: { type: 'text'; text: string }[] 26 | isError?: boolean 27 | } 28 | 29 | /** 30 | * OpenAI MCP fetch tool - retrieves the full contents of a task or project by ID. 31 | * 32 | * This tool follows the OpenAI MCP fetch tool specification: 33 | * @see https://platform.openai.com/docs/mcp#fetch-tool 34 | */ 35 | const fetch = { 36 | name: ToolNames.FETCH, 37 | description: 38 | 'Fetch the full contents of a task or project by its ID. The ID should be in the format "task:{id}" or "project:{id}".', 39 | parameters: ArgsSchema, 40 | async execute(args, client): Promise { 41 | try { 42 | const { id } = args 43 | 44 | // Parse the composite ID 45 | const [type, objectId] = id.split(':', 2) 46 | 47 | if (!objectId || (type !== 'task' && type !== 'project')) { 48 | throw new Error( 49 | 'Invalid ID format. Expected "task:{id}" or "project:{id}". Example: "task:8485093748" or "project:6cfCcrrCFg2xP94Q"', 50 | ) 51 | } 52 | 53 | let result: FetchResult 54 | 55 | if (type === 'task') { 56 | // Fetch task 57 | const task = await client.getTask(objectId) 58 | const mappedTask = mapTask(task) 59 | 60 | // Build text content 61 | const textParts = [mappedTask.content] 62 | if (mappedTask.description) { 63 | textParts.push(`\n\nDescription: ${mappedTask.description}`) 64 | } 65 | if (mappedTask.dueDate) { 66 | textParts.push(`\nDue: ${mappedTask.dueDate}`) 67 | } 68 | if (mappedTask.labels.length > 0) { 69 | textParts.push(`\nLabels: ${mappedTask.labels.join(', ')}`) 70 | } 71 | 72 | result = { 73 | id: `task:${mappedTask.id}`, 74 | title: mappedTask.content, 75 | text: textParts.join(''), 76 | url: buildTodoistUrl('task', mappedTask.id), 77 | metadata: { 78 | priority: mappedTask.priority, 79 | projectId: mappedTask.projectId, 80 | sectionId: mappedTask.sectionId, 81 | parentId: mappedTask.parentId, 82 | recurring: mappedTask.recurring, 83 | duration: mappedTask.duration, 84 | responsibleUid: mappedTask.responsibleUid, 85 | assignedByUid: mappedTask.assignedByUid, 86 | }, 87 | } 88 | } else { 89 | // Fetch project 90 | const project = await client.getProject(objectId) 91 | const mappedProject = mapProject(project) 92 | 93 | // Build text content 94 | const textParts = [mappedProject.name] 95 | if (mappedProject.isShared) { 96 | textParts.push('\n\nShared project') 97 | } 98 | if (mappedProject.isFavorite) { 99 | textParts.push('\nFavorite: Yes') 100 | } 101 | 102 | result = { 103 | id: `project:${mappedProject.id}`, 104 | title: mappedProject.name, 105 | text: textParts.join(''), 106 | url: buildTodoistUrl('project', mappedProject.id), 107 | metadata: { 108 | color: mappedProject.color, 109 | isFavorite: mappedProject.isFavorite, 110 | isShared: mappedProject.isShared, 111 | parentId: mappedProject.parentId, 112 | inboxProject: mappedProject.inboxProject, 113 | viewStyle: mappedProject.viewStyle, 114 | }, 115 | } 116 | } 117 | 118 | // Return as JSON-encoded string in a text content item (OpenAI MCP spec) 119 | const jsonText = JSON.stringify(result) 120 | return { content: [{ type: 'text' as const, text: jsonText }] } 121 | } catch (error) { 122 | const message = error instanceof Error ? error.message : 'An unknown error occurred' 123 | return getErrorOutput(message) 124 | } 125 | }, 126 | } satisfies TodoistTool 127 | 128 | export { fetch } 129 | -------------------------------------------------------------------------------- /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 { formatNextSteps } from '../utils/response-builders.js' 7 | import { ToolNames } from '../utils/tool-names.js' 8 | 9 | const { ADD_COMMENTS, UPDATE_COMMENTS, DELETE_OBJECT } = ToolNames 10 | 11 | const ArgsSchema = { 12 | taskId: z.string().optional().describe('Find comments for a specific task.'), 13 | projectId: z.string().optional().describe('Find comments for a specific project.'), 14 | commentId: z.string().optional().describe('Get a specific comment by ID.'), 15 | cursor: z.string().optional().describe('Pagination cursor for retrieving more results.'), 16 | limit: z 17 | .number() 18 | .int() 19 | .min(1) 20 | .max(ApiLimits.COMMENTS_MAX) 21 | .optional() 22 | .describe('Maximum number of comments to return'), 23 | } 24 | 25 | const findComments = { 26 | name: ToolNames.FIND_COMMENTS, 27 | description: 28 | 'Find comments by task, project, or get a specific comment by ID. Exactly one of taskId, projectId, or commentId must be provided.', 29 | parameters: ArgsSchema, 30 | async execute(args, client) { 31 | // Validate that exactly one search parameter is provided 32 | const searchParams = [args.taskId, args.projectId, args.commentId].filter(Boolean) 33 | if (searchParams.length === 0) { 34 | throw new Error('Must provide exactly one of: taskId, projectId, or commentId.') 35 | } 36 | if (searchParams.length > 1) { 37 | throw new Error( 38 | 'Cannot provide multiple search parameters. Choose one of: taskId, projectId, or commentId.', 39 | ) 40 | } 41 | 42 | let comments: Comment[] 43 | let hasMore = false 44 | let nextCursor: string | null = null 45 | 46 | if (args.commentId) { 47 | // Get single comment 48 | const comment = await client.getComment(args.commentId) 49 | comments = [comment] 50 | } else if (args.taskId) { 51 | // Get comments by task 52 | const response = await client.getComments({ 53 | taskId: args.taskId, 54 | cursor: args.cursor || null, 55 | limit: args.limit || ApiLimits.COMMENTS_DEFAULT, 56 | }) 57 | comments = response.results 58 | hasMore = response.nextCursor !== null 59 | nextCursor = response.nextCursor 60 | } else if (args.projectId) { 61 | // Get comments by project 62 | const response = await client.getComments({ 63 | projectId: args.projectId, 64 | cursor: args.cursor || null, 65 | limit: args.limit || ApiLimits.COMMENTS_DEFAULT, 66 | }) 67 | comments = response.results 68 | hasMore = response.nextCursor !== null 69 | nextCursor = response.nextCursor 70 | } else { 71 | // This should never happen due to validation, but TypeScript needs it 72 | throw new Error('Invalid state: no search parameter provided') 73 | } 74 | 75 | const textContent = generateTextContent({ 76 | comments, 77 | searchType: args.commentId ? 'single' : args.taskId ? 'task' : 'project', 78 | searchId: args.commentId || args.taskId || args.projectId || '', 79 | hasMore, 80 | nextCursor, 81 | }) 82 | 83 | return getToolOutput({ 84 | textContent, 85 | structuredContent: { 86 | comments, 87 | searchType: args.commentId ? 'single' : args.taskId ? 'task' : 'project', 88 | searchId: args.commentId || args.taskId || args.projectId || '', 89 | hasMore, 90 | nextCursor, 91 | totalCount: comments.length, 92 | }, 93 | }) 94 | }, 95 | } satisfies TodoistTool 96 | 97 | function generateTextContent({ 98 | comments, 99 | searchType, 100 | searchId, 101 | hasMore, 102 | nextCursor, 103 | }: { 104 | comments: Comment[] 105 | searchType: 'single' | 'task' | 'project' 106 | searchId: string 107 | hasMore: boolean 108 | nextCursor: string | null 109 | }): string { 110 | if (comments.length === 0) { 111 | return `No comments found for ${searchType}${searchType !== 'single' ? ` ${searchId}` : ''}` 112 | } 113 | 114 | // Build summary 115 | let summary: string 116 | if (searchType === 'single') { 117 | const comment = comments[0] 118 | if (!comment) { 119 | return 'Comment not found' 120 | } 121 | const hasAttachment = comment.fileAttachment !== null 122 | const attachmentInfo = hasAttachment 123 | ? ` • Has attachment: ${comment.fileAttachment?.fileName || 'file'}` 124 | : '' 125 | summary = `Found comment${attachmentInfo} • id=${comment.id}` 126 | } else { 127 | const attachmentCount = comments.filter((c) => c.fileAttachment !== null).length 128 | const attachmentInfo = attachmentCount > 0 ? ` (${attachmentCount} with attachments)` : '' 129 | const commentsLabel = comments.length === 1 ? 'comment' : 'comments' 130 | summary = `Found ${comments.length} ${commentsLabel} for ${searchType} ${searchId}${attachmentInfo}` 131 | 132 | if (hasMore) { 133 | summary += ' • More available' 134 | } 135 | } 136 | 137 | // Context-aware next steps 138 | const nextSteps: string[] = [] 139 | 140 | if (searchType === 'single') { 141 | const comment = comments[0] 142 | if (comment) { 143 | nextSteps.push(`Use ${UPDATE_COMMENTS} with id=${comment.id} to edit content`) 144 | nextSteps.push(`Use ${DELETE_OBJECT} with type=comment id=${comment.id} to remove`) 145 | 146 | // Suggest viewing related comments 147 | if (comment.taskId) { 148 | nextSteps.push( 149 | `Use ${ToolNames.FIND_COMMENTS} with taskId=${comment.taskId} to see all task comments`, 150 | ) 151 | } else if (comment.projectId) { 152 | nextSteps.push( 153 | `Use ${ToolNames.FIND_COMMENTS} with projectId=${comment.projectId} to see all project comments`, 154 | ) 155 | } 156 | } 157 | } else { 158 | // Multiple comments 159 | nextSteps.push(`Use ${ADD_COMMENTS} with ${searchType}Id=${searchId} to add new comment`) 160 | 161 | if (comments.length > 0) { 162 | nextSteps.push( 163 | `Use ${ToolNames.FIND_COMMENTS} with commentId to view specific comment details`, 164 | ) 165 | } 166 | 167 | // Pagination 168 | if (hasMore && nextCursor) { 169 | nextSteps.push( 170 | `Use ${ToolNames.FIND_COMMENTS} with cursor="${nextCursor}" to get more results`, 171 | ) 172 | } 173 | } 174 | 175 | const next = formatNextSteps(nextSteps) 176 | return `${summary}\n${next}` 177 | } 178 | 179 | export { findComments } 180 | -------------------------------------------------------------------------------- /src/tools/find-completed-tasks.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { getToolOutput } from '../mcp-helpers.js' 3 | import type { TodoistTool } from '../todoist-tool.js' 4 | import { mapTask } from '../tool-helpers.js' 5 | import { ApiLimits } from '../utils/constants.js' 6 | import { generateLabelsFilter, LabelsSchema } from '../utils/labels.js' 7 | import { previewTasks, summarizeList } from '../utils/response-builders.js' 8 | import { ToolNames } from '../utils/tool-names.js' 9 | 10 | const { FIND_TASKS_BY_DATE, GET_OVERVIEW } = ToolNames 11 | 12 | const ArgsSchema = { 13 | getBy: z 14 | .enum(['completion', 'due']) 15 | .default('completion') 16 | .describe( 17 | '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).', 18 | ), 19 | since: z 20 | .string() 21 | .date() 22 | .regex(/^\d{4}-\d{2}-\d{2}$/) 23 | .describe('The start date to get the tasks for. Format: YYYY-MM-DD.'), 24 | until: z 25 | .string() 26 | .date() 27 | .regex(/^\d{4}-\d{2}-\d{2}$/) 28 | .describe('The start date to get the tasks for. Format: YYYY-MM-DD.'), 29 | workspaceId: z.string().optional().describe('The ID of the workspace to get the tasks for.'), 30 | projectId: z.string().optional().describe('The ID of the project to get the tasks for.'), 31 | sectionId: z.string().optional().describe('The ID of the section to get the tasks for.'), 32 | parentId: z.string().optional().describe('The ID of the parent task to get the tasks for.'), 33 | 34 | limit: z 35 | .number() 36 | .int() 37 | .min(1) 38 | .max(ApiLimits.COMPLETED_TASKS_MAX) 39 | .default(ApiLimits.COMPLETED_TASKS_DEFAULT) 40 | .describe('The maximum number of tasks to return.'), 41 | cursor: z 42 | .string() 43 | .optional() 44 | .describe( 45 | 'The cursor to get the next page of tasks (cursor is obtained from the previous call to this tool, with the same parameters).', 46 | ), 47 | ...LabelsSchema, 48 | } 49 | 50 | const findCompletedTasks = { 51 | name: ToolNames.FIND_COMPLETED_TASKS, 52 | description: 'Get completed tasks.', 53 | parameters: ArgsSchema, 54 | async execute(args, client) { 55 | const { getBy, labels, labelsOperator, since, until, ...rest } = args 56 | const labelsFilter = generateLabelsFilter(labels, labelsOperator) 57 | 58 | // Get user timezone to convert local dates to UTC 59 | const user = await client.getUser() 60 | const userGmtOffset = user.tzInfo?.gmtString || '+00:00' 61 | 62 | // Convert user's local date to UTC timestamps 63 | // This ensures we capture the entire day from the user's perspective 64 | const sinceWithOffset = `${since}T00:00:00${userGmtOffset}` 65 | const untilWithOffset = `${until}T23:59:59${userGmtOffset}` 66 | 67 | // Parse and convert to UTC 68 | const sinceDateTime = new Date(sinceWithOffset).toISOString() 69 | const untilDateTime = new Date(untilWithOffset).toISOString() 70 | 71 | const { items, nextCursor } = 72 | getBy === 'completion' 73 | ? await client.getCompletedTasksByCompletionDate({ 74 | ...rest, 75 | since: sinceDateTime, 76 | until: untilDateTime, 77 | ...(labelsFilter ? { filterQuery: labelsFilter, filterLang: 'en' } : {}), 78 | }) 79 | : await client.getCompletedTasksByDueDate({ 80 | ...rest, 81 | since: sinceDateTime, 82 | until: untilDateTime, 83 | ...(labelsFilter ? { filterQuery: labelsFilter, filterLang: 'en' } : {}), 84 | }) 85 | const mappedTasks = items.map(mapTask) 86 | 87 | const textContent = generateTextContent({ 88 | tasks: mappedTasks, 89 | args, 90 | nextCursor, 91 | }) 92 | 93 | return getToolOutput({ 94 | textContent, 95 | structuredContent: { 96 | tasks: mappedTasks, 97 | nextCursor, 98 | totalCount: mappedTasks.length, 99 | hasMore: Boolean(nextCursor), 100 | appliedFilters: args, 101 | }, 102 | }) 103 | }, 104 | } satisfies TodoistTool 105 | 106 | function generateTextContent({ 107 | tasks, 108 | args, 109 | nextCursor, 110 | }: { 111 | tasks: ReturnType[] 112 | args: z.infer> 113 | nextCursor: string | null 114 | }) { 115 | // Generate subject description 116 | const getByText = args.getBy === 'completion' ? 'completed' : 'due' 117 | const subject = `Completed tasks (by ${getByText} date)` 118 | 119 | // Generate filter hints 120 | const filterHints: string[] = [] 121 | filterHints.push(`${getByText} date: ${args.since} to ${args.until}`) 122 | if (args.projectId) filterHints.push(`project: ${args.projectId}`) 123 | if (args.sectionId) filterHints.push(`section: ${args.sectionId}`) 124 | if (args.parentId) filterHints.push(`parent: ${args.parentId}`) 125 | if (args.workspaceId) filterHints.push(`workspace: ${args.workspaceId}`) 126 | 127 | // Add label filter information 128 | if (args.labels && args.labels.length > 0) { 129 | const labelText = args.labels 130 | .map((label) => `@${label}`) 131 | .join(args.labelsOperator === 'and' ? ' & ' : ' | ') 132 | filterHints.push(`labels: ${labelText}`) 133 | } 134 | 135 | // Generate helpful suggestions for empty results 136 | const zeroReasonHints: string[] = [] 137 | if (tasks.length === 0) { 138 | zeroReasonHints.push('No tasks completed in this date range') 139 | zeroReasonHints.push('Try expanding the date range') 140 | if (args.projectId || args.sectionId || args.parentId) { 141 | zeroReasonHints.push('Try removing project/section/parent filters') 142 | } 143 | if (args.getBy === 'due') { 144 | zeroReasonHints.push('Try switching to "completion" date instead') 145 | } 146 | } 147 | 148 | // Generate contextual next steps 149 | const nextSteps: string[] = [] 150 | if (tasks.length > 0) { 151 | nextSteps.push( 152 | `Use ${FIND_TASKS_BY_DATE} for active tasks or ${GET_OVERVIEW} for current productivity.`, 153 | ) 154 | if (tasks.some((task) => task.recurring)) { 155 | nextSteps.push('Recurring tasks will automatically create new instances.') 156 | } 157 | } 158 | 159 | return summarizeList({ 160 | subject, 161 | count: tasks.length, 162 | limit: args.limit, 163 | nextCursor: nextCursor ?? undefined, 164 | filterHints, 165 | previewLines: previewTasks(tasks, Math.min(tasks.length, args.limit)), 166 | zeroReasonHints, 167 | nextSteps, 168 | }) 169 | } 170 | 171 | export { findCompletedTasks } 172 | -------------------------------------------------------------------------------- /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 { summarizeList } from '../utils/response-builders.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | import { type ProjectCollaborator, userResolver } from '../utils/user-resolver.js' 8 | 9 | const { FIND_PROJECTS, ADD_TASKS, UPDATE_TASKS } = ToolNames 10 | 11 | const ArgsSchema = { 12 | projectId: z.string().min(1).describe('The ID of the project to search for collaborators in.'), 13 | searchTerm: z 14 | .string() 15 | .optional() 16 | .describe( 17 | 'Search for a collaborator by name or email (partial and case insensitive match). If omitted, all collaborators in the project are returned.', 18 | ), 19 | } 20 | 21 | const findProjectCollaborators = { 22 | name: ToolNames.FIND_PROJECT_COLLABORATORS, 23 | description: 'Search for collaborators by name or other criteria in a project.', 24 | parameters: ArgsSchema, 25 | async execute(args, client) { 26 | const { projectId, searchTerm } = args 27 | 28 | // First, validate that the project exists and get basic info 29 | let projectName = projectId 30 | let project: Project 31 | try { 32 | project = await client.getProject(projectId) 33 | if (!project) { 34 | throw new Error(`Project with ID "${projectId}" not found or not accessible`) 35 | } 36 | projectName = project.name 37 | 38 | if (!project.isShared) { 39 | 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` 40 | 41 | return getToolOutput({ 42 | textContent, 43 | structuredContent: { 44 | collaborators: [], 45 | projectInfo: { 46 | id: projectId, 47 | name: projectName, 48 | isShared: false, 49 | }, 50 | totalCount: 0, 51 | appliedFilters: args, 52 | }, 53 | }) 54 | } 55 | } catch (error) { 56 | throw new Error( 57 | `Failed to access project "${projectId}": ${error instanceof Error ? error.message : 'Unknown error'}`, 58 | ) 59 | } 60 | 61 | // Get collaborators for the project 62 | const allCollaborators = await userResolver.getProjectCollaborators(client, projectId) 63 | 64 | if (allCollaborators.length === 0) { 65 | 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` 66 | 67 | return getToolOutput({ 68 | textContent, 69 | structuredContent: { 70 | collaborators: [], 71 | projectInfo: { 72 | id: projectId, 73 | name: projectName, 74 | isShared: true, 75 | }, 76 | totalCount: 0, 77 | appliedFilters: args, 78 | }, 79 | }) 80 | } 81 | 82 | // Filter collaborators if search term provided 83 | let filteredCollaborators = allCollaborators 84 | if (searchTerm) { 85 | const searchLower = searchTerm.toLowerCase().trim() 86 | filteredCollaborators = allCollaborators.filter( 87 | (collaborator) => 88 | collaborator.name.toLowerCase().includes(searchLower) || 89 | collaborator.email.toLowerCase().includes(searchLower), 90 | ) 91 | } 92 | 93 | const textContent = generateTextContent({ 94 | collaborators: filteredCollaborators, 95 | projectName, 96 | searchTerm, 97 | totalAvailable: allCollaborators.length, 98 | }) 99 | 100 | return getToolOutput({ 101 | textContent, 102 | structuredContent: { 103 | collaborators: filteredCollaborators, 104 | projectInfo: { 105 | id: projectId, 106 | name: projectName, 107 | isShared: true, 108 | }, 109 | totalCount: filteredCollaborators.length, 110 | totalAvailable: allCollaborators.length, 111 | appliedFilters: args, 112 | }, 113 | }) 114 | }, 115 | } satisfies TodoistTool 116 | 117 | function generateTextContent({ 118 | collaborators, 119 | projectName, 120 | searchTerm, 121 | totalAvailable, 122 | }: { 123 | collaborators: ProjectCollaborator[] 124 | projectName: string 125 | searchTerm?: string 126 | totalAvailable: number 127 | }) { 128 | const subject = searchTerm 129 | ? `Project collaborators matching "${searchTerm}"` 130 | : 'Project collaborators' 131 | 132 | const filterHints: string[] = [] 133 | if (searchTerm) { 134 | filterHints.push(`matching "${searchTerm}"`) 135 | } 136 | filterHints.push(`in project "${projectName}"`) 137 | 138 | let previewLines: string[] = [] 139 | if (collaborators.length > 0) { 140 | previewLines = collaborators.slice(0, 10).map((collaborator) => { 141 | const displayName = collaborator.name || 'Unknown Name' 142 | const email = collaborator.email || 'No email' 143 | return `• ${displayName} (${email}) - ID: ${collaborator.id}` 144 | }) 145 | 146 | if (collaborators.length > 10) { 147 | previewLines.push(`... and ${collaborators.length - 10} more`) 148 | } 149 | } 150 | 151 | const zeroReasonHints: string[] = [] 152 | if (collaborators.length === 0) { 153 | if (searchTerm) { 154 | zeroReasonHints.push(`No collaborators match "${searchTerm}"`) 155 | zeroReasonHints.push('Try a broader search term or check spelling') 156 | if (totalAvailable > 0) { 157 | zeroReasonHints.push(`${totalAvailable} collaborators available without filter`) 158 | } 159 | } else { 160 | zeroReasonHints.push('Project has no collaborators') 161 | zeroReasonHints.push('Share the project to add collaborators') 162 | } 163 | } 164 | 165 | const nextSteps: string[] = [] 166 | if (collaborators.length > 0) { 167 | nextSteps.push(`Use ${ADD_TASKS} with responsibleUser to assign new tasks`) 168 | nextSteps.push(`Use ${UPDATE_TASKS} with responsibleUser to reassign existing tasks`) 169 | nextSteps.push('Use collaborator names, emails, or IDs for assignments') 170 | } else { 171 | nextSteps.push(`Use ${FIND_PROJECTS} to find other projects`) 172 | if (searchTerm && totalAvailable > 0) { 173 | nextSteps.push('Try searching without filters to see all collaborators') 174 | } 175 | } 176 | 177 | return summarizeList({ 178 | subject, 179 | count: collaborators.length, 180 | filterHints, 181 | previewLines: previewLines.join('\n'), 182 | zeroReasonHints, 183 | nextSteps, 184 | }) 185 | } 186 | 187 | export { findProjectCollaborators } 188 | -------------------------------------------------------------------------------- /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 { formatProjectPreview, summarizeList } from '../utils/response-builders.js' 7 | import { ToolNames } from '../utils/tool-names.js' 8 | 9 | const { ADD_PROJECTS, FIND_TASKS } = ToolNames 10 | 11 | const ArgsSchema = { 12 | search: z 13 | .string() 14 | .optional() 15 | .describe( 16 | 'Search for a project by name (partial and case insensitive match). If omitted, all projects are returned.', 17 | ), 18 | limit: z 19 | .number() 20 | .int() 21 | .min(1) 22 | .max(ApiLimits.PROJECTS_MAX) 23 | .default(ApiLimits.PROJECTS_DEFAULT) 24 | .describe('The maximum number of projects to return.'), 25 | cursor: z 26 | .string() 27 | .optional() 28 | .describe( 29 | 'The cursor to get the next page of projects (cursor is obtained from the previous call to this tool, with the same parameters).', 30 | ), 31 | } 32 | 33 | const findProjects = { 34 | name: ToolNames.FIND_PROJECTS, 35 | description: 36 | 'List all projects or search for projects by name. If search parameter is omitted, all projects are returned.', 37 | parameters: ArgsSchema, 38 | async execute(args, client) { 39 | const { results, nextCursor } = await client.getProjects({ 40 | limit: args.limit, 41 | cursor: args.cursor ?? null, 42 | }) 43 | const searchLower = args.search ? args.search.toLowerCase() : undefined 44 | const filtered = searchLower 45 | ? results.filter((project) => project.name.toLowerCase().includes(searchLower)) 46 | : results 47 | const projects = filtered.map(mapProject) 48 | 49 | return getToolOutput({ 50 | textContent: generateTextContent({ 51 | projects, 52 | args, 53 | nextCursor, 54 | }), 55 | structuredContent: { 56 | projects, 57 | nextCursor, 58 | totalCount: projects.length, 59 | hasMore: Boolean(nextCursor), 60 | appliedFilters: args, 61 | }, 62 | }) 63 | }, 64 | } satisfies TodoistTool 65 | 66 | function generateTextContent({ 67 | projects, 68 | args, 69 | nextCursor, 70 | }: { 71 | projects: ReturnType[] 72 | args: z.infer> 73 | nextCursor: string | null 74 | }) { 75 | // Generate subject description 76 | const subject = args.search ? `Projects matching "${args.search}"` : 'Projects' 77 | 78 | // Generate filter hints 79 | const filterHints: string[] = [] 80 | if (args.search) { 81 | filterHints.push(`search: "${args.search}"`) 82 | } 83 | 84 | // Generate project preview lines 85 | const previewLimit = 10 86 | const previewProjects = projects.slice(0, previewLimit) 87 | const previewLines = previewProjects.map(formatProjectPreview).join('\n') 88 | const remainingCount = projects.length - previewLimit 89 | const previewWithMore = 90 | remainingCount > 0 ? `${previewLines}\n …and ${remainingCount} more` : previewLines 91 | 92 | // Generate helpful suggestions for empty results 93 | const zeroReasonHints: string[] = [] 94 | if (projects.length === 0) { 95 | if (args.search) { 96 | zeroReasonHints.push('Try broader search terms') 97 | zeroReasonHints.push('Check spelling') 98 | zeroReasonHints.push('Remove search to see all projects') 99 | } else { 100 | zeroReasonHints.push('No projects created yet') 101 | zeroReasonHints.push(`Use ${ADD_PROJECTS} to create a project`) 102 | } 103 | } 104 | 105 | // Generate contextual next steps 106 | const nextSteps: string[] = [] 107 | if (projects.length > 0) { 108 | nextSteps.push(`Use ${FIND_TASKS} with projectId to see tasks in specific projects.`) 109 | if (projects.some((p) => p.isFavorite)) { 110 | nextSteps.push('Favorite projects appear first in most Todoist views.') 111 | } 112 | } 113 | 114 | return summarizeList({ 115 | subject, 116 | count: projects.length, 117 | limit: args.limit, 118 | nextCursor: nextCursor ?? undefined, 119 | filterHints, 120 | previewLines: previewWithMore, 121 | zeroReasonHints, 122 | nextSteps, 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 { summarizeList } from '../utils/response-builders.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const { ADD_SECTIONS, UPDATE_SECTIONS, FIND_TASKS, UPDATE_TASKS, DELETE_OBJECT } = ToolNames 9 | 10 | const ArgsSchema = { 11 | projectId: z.string().min(1).describe('The ID of the project to search sections in.'), 12 | search: z 13 | .string() 14 | .optional() 15 | .describe( 16 | 'Search for a section by name (partial and case insensitive match). If omitted, all sections in the project are returned.', 17 | ), 18 | } 19 | 20 | type SectionSummary = { 21 | id: string 22 | name: string 23 | } 24 | 25 | const findSections = { 26 | name: ToolNames.FIND_SECTIONS, 27 | description: 'Search for sections by name or other criteria in a project.', 28 | parameters: ArgsSchema, 29 | async execute(args, client) { 30 | const { results } = await client.getSections({ 31 | projectId: args.projectId, 32 | }) 33 | const searchLower = args.search ? args.search.toLowerCase() : undefined 34 | const filtered = searchLower 35 | ? results.filter((section: Section) => section.name.toLowerCase().includes(searchLower)) 36 | : results 37 | 38 | const sections = filtered.map((section) => ({ 39 | id: section.id, 40 | name: section.name, 41 | })) 42 | 43 | const textContent = generateTextContent({ 44 | sections, 45 | projectId: args.projectId, 46 | search: args.search, 47 | }) 48 | 49 | return getToolOutput({ 50 | textContent, 51 | structuredContent: { 52 | sections, 53 | totalCount: sections.length, 54 | appliedFilters: args, 55 | }, 56 | }) 57 | }, 58 | } satisfies TodoistTool 59 | 60 | function generateTextContent({ 61 | sections, 62 | projectId, 63 | search, 64 | }: { 65 | sections: SectionSummary[] 66 | projectId: string 67 | search?: string 68 | }): string { 69 | const zeroReasonHints: string[] = [] 70 | 71 | if (search) { 72 | zeroReasonHints.push('Try broader search terms') 73 | zeroReasonHints.push('Check spelling') 74 | zeroReasonHints.push('Remove search to see all sections') 75 | } else { 76 | zeroReasonHints.push('Project has no sections yet') 77 | zeroReasonHints.push(`Use ${ADD_SECTIONS} to create sections`) 78 | } 79 | 80 | // Data-driven next steps based on results 81 | const nextSteps: string[] = [] 82 | 83 | if (sections.length > 0) { 84 | // Suggestions based on number of sections found 85 | if (sections.length === 1) { 86 | const sectionId = sections[0]?.id 87 | nextSteps.push(`Use ${FIND_TASKS} with sectionId=${sectionId} to see tasks`) 88 | nextSteps.push(`Use ${ADD_SECTIONS} to create additional sections for organization`) 89 | } else if (sections.length > 8) { 90 | nextSteps.push( 91 | 'Consider consolidating sections - many small sections can reduce productivity', 92 | ) 93 | nextSteps.push(`Use ${UPDATE_TASKS} to move tasks between sections`) 94 | nextSteps.push(`Use ${DELETE_OBJECT} with type=section to delete empty sections`) 95 | } else { 96 | nextSteps.push(`Use ${FIND_TASKS} with sectionId to see tasks in specific sections`) 97 | nextSteps.push(`Use ${UPDATE_SECTIONS} to modify section names`) 98 | } 99 | 100 | // Search-specific suggestions 101 | if (search) { 102 | nextSteps.push('Remove search parameter to see all sections in this project') 103 | } 104 | } else { 105 | // Empty result suggestions are already handled in zeroReasonHints 106 | // No additional nextSteps needed for empty results 107 | } 108 | 109 | const subject = search 110 | ? `Sections in project ${projectId} matching "${search}"` 111 | : `Sections in project ${projectId}` 112 | 113 | const previewLines = 114 | sections.length > 0 115 | ? sections.map((section) => ` ${section.name} • id=${section.id}`).join('\n') 116 | : undefined 117 | 118 | return summarizeList({ 119 | subject, 120 | count: sections.length, 121 | previewLines, 122 | zeroReasonHints, 123 | nextSteps, 124 | }) 125 | } 126 | 127 | export { findSections } 128 | -------------------------------------------------------------------------------- /src/tools/search.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { getErrorOutput } from '../mcp-helpers.js' 3 | import type { TodoistTool } from '../todoist-tool.js' 4 | import { buildTodoistUrl, getTasksByFilter } from '../tool-helpers.js' 5 | import { ApiLimits } from '../utils/constants.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const ArgsSchema = { 9 | query: z.string().min(1).describe('The search query string to find tasks and projects.'), 10 | } 11 | 12 | type SearchResult = { 13 | id: string 14 | title: string 15 | url: string 16 | } 17 | 18 | type SearchToolOutput = { 19 | content: { type: 'text'; text: string }[] 20 | isError?: boolean 21 | } 22 | 23 | /** 24 | * OpenAI MCP search tool - returns a list of relevant search results from Todoist. 25 | * 26 | * This tool follows the OpenAI MCP search tool specification: 27 | * @see https://platform.openai.com/docs/mcp#search-tool 28 | */ 29 | const search = { 30 | name: ToolNames.SEARCH, 31 | description: 32 | 'Search across tasks and projects in Todoist. Returns a list of relevant results with IDs, titles, and URLs.', 33 | parameters: ArgsSchema, 34 | async execute(args, client): Promise { 35 | try { 36 | const { query } = args 37 | 38 | // Search both tasks and projects in parallel 39 | // Use TASKS_MAX for search since this tool doesn't support pagination 40 | const [tasksResult, projectsResponse] = await Promise.all([ 41 | getTasksByFilter({ 42 | client, 43 | query: `search: ${query}`, 44 | limit: ApiLimits.TASKS_MAX, 45 | cursor: undefined, 46 | }), 47 | client.getProjects({ limit: ApiLimits.PROJECTS_MAX }), 48 | ]) 49 | 50 | // Filter projects by search query (case-insensitive) 51 | const searchLower = query.toLowerCase() 52 | const matchingProjects = projectsResponse.results.filter((project) => 53 | project.name.toLowerCase().includes(searchLower), 54 | ) 55 | 56 | // Build results array 57 | const results: SearchResult[] = [] 58 | 59 | // Add task results with composite IDs 60 | for (const task of tasksResult.tasks) { 61 | results.push({ 62 | id: `task:${task.id}`, 63 | title: task.content, 64 | url: buildTodoistUrl('task', task.id), 65 | }) 66 | } 67 | 68 | // Add project results with composite IDs 69 | for (const project of matchingProjects) { 70 | results.push({ 71 | id: `project:${project.id}`, 72 | title: project.name, 73 | url: buildTodoistUrl('project', project.id), 74 | }) 75 | } 76 | 77 | // Return as JSON-encoded string in a text content item (OpenAI MCP spec) 78 | const jsonText = JSON.stringify({ results }) 79 | return { content: [{ type: 'text' as const, text: jsonText }] } 80 | } catch (error) { 81 | const message = error instanceof Error ? error.message : 'An unknown error occurred' 82 | return getErrorOutput(message) 83 | } 84 | }, 85 | } satisfies TodoistTool 86 | 87 | export { search } 88 | -------------------------------------------------------------------------------- /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 { formatNextSteps } from '../utils/response-builders.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const { FIND_COMMENTS, DELETE_OBJECT } = ToolNames 9 | 10 | const CommentUpdateSchema = z.object({ 11 | id: z.string().min(1).describe('The ID of the comment to update.'), 12 | content: z.string().min(1).describe('The new content for the comment.'), 13 | }) 14 | 15 | const ArgsSchema = { 16 | comments: z.array(CommentUpdateSchema).min(1).describe('The comments to update.'), 17 | } 18 | 19 | const updateComments = { 20 | name: ToolNames.UPDATE_COMMENTS, 21 | description: 'Update multiple existing comments with new content.', 22 | parameters: ArgsSchema, 23 | async execute(args, client) { 24 | const { comments } = args 25 | 26 | const updateCommentPromises = comments.map(async (comment) => { 27 | return await client.updateComment(comment.id, { content: comment.content }) 28 | }) 29 | 30 | const updatedComments = await Promise.all(updateCommentPromises) 31 | 32 | const textContent = generateTextContent({ 33 | comments: updatedComments, 34 | }) 35 | 36 | return getToolOutput({ 37 | textContent, 38 | structuredContent: { 39 | comments: updatedComments, 40 | totalCount: updatedComments.length, 41 | updatedCommentIds: updatedComments.map((comment) => comment.id), 42 | appliedOperations: { 43 | updateCount: updatedComments.length, 44 | }, 45 | }, 46 | }) 47 | }, 48 | } satisfies TodoistTool 49 | 50 | function generateNextSteps(comments: Comment[]): string[] { 51 | const nextSteps: string[] = [] 52 | 53 | // Early return for empty comments 54 | if (comments.length === 0) { 55 | return nextSteps 56 | } 57 | 58 | // Multiple comments case 59 | if (comments.length > 1) { 60 | nextSteps.push(`Use ${FIND_COMMENTS} to view comments by task or project`) 61 | nextSteps.push(`Use ${DELETE_OBJECT} with type=comment to remove comments`) 62 | return nextSteps 63 | } 64 | 65 | // Single comment case 66 | const comment = comments[0] 67 | if (!comment) return nextSteps 68 | 69 | if (comment.taskId) { 70 | nextSteps.push( 71 | `Use ${FIND_COMMENTS} with taskId=${comment.taskId} to see all task comments`, 72 | ) 73 | } else if (comment.projectId) { 74 | nextSteps.push( 75 | `Use ${FIND_COMMENTS} with projectId=${comment.projectId} to see all project comments`, 76 | ) 77 | } 78 | nextSteps.push(`Use ${DELETE_OBJECT} with type=comment id=${comment.id} to remove comment`) 79 | return nextSteps 80 | } 81 | 82 | function generateTextContent({ comments }: { comments: Comment[] }): string { 83 | // Group comments by entity type and count 84 | const taskComments = comments.filter((c) => c.taskId).length 85 | const projectComments = comments.filter((c) => c.projectId).length 86 | 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 ? `Updated ${parts.join(' and ')}` : 'No comments updated' 97 | 98 | const nextSteps = generateNextSteps(comments) 99 | const next = formatNextSteps(nextSteps) 100 | return `${summary}\n${next}` 101 | } 102 | 103 | export { updateComments } 104 | -------------------------------------------------------------------------------- /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 { formatNextSteps } from '../utils/response-builders.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const { FIND_PROJECTS, FIND_TASKS, GET_OVERVIEW } = ToolNames 9 | 10 | const ProjectUpdateSchema = z.object({ 11 | id: z.string().min(1).describe('The ID of the project to update.'), 12 | name: z.string().min(1).optional().describe('The new name of the project.'), 13 | isFavorite: z.boolean().optional().describe('Whether the project is a favorite.'), 14 | viewStyle: z.enum(['list', 'board', 'calendar']).optional().describe('The project view style.'), 15 | }) 16 | 17 | type ProjectUpdate = z.infer 18 | 19 | const ArgsSchema = { 20 | projects: z.array(ProjectUpdateSchema).min(1).describe('The projects to update.'), 21 | } 22 | 23 | const updateProjects = { 24 | name: ToolNames.UPDATE_PROJECTS, 25 | description: 'Update multiple existing projects with new values.', 26 | parameters: ArgsSchema, 27 | async execute(args, client) { 28 | const { projects } = args 29 | const updateProjectsPromises = projects.map(async (project) => { 30 | if (!hasUpdatesToMake(project)) { 31 | return undefined 32 | } 33 | 34 | const { id, ...updateArgs } = project 35 | return await client.updateProject(id, updateArgs) 36 | }) 37 | 38 | const updatedProjects = (await Promise.all(updateProjectsPromises)).filter( 39 | (project): project is PersonalProject | WorkspaceProject => project !== undefined, 40 | ) 41 | 42 | const textContent = generateTextContent({ 43 | projects: updatedProjects, 44 | args, 45 | }) 46 | 47 | return getToolOutput({ 48 | textContent, 49 | structuredContent: { 50 | projects: updatedProjects, 51 | totalCount: updatedProjects.length, 52 | updatedProjectIds: updatedProjects.map((project) => project.id), 53 | appliedOperations: { 54 | updateCount: updatedProjects.length, 55 | skippedCount: projects.length - updatedProjects.length, 56 | }, 57 | }, 58 | }) 59 | }, 60 | } satisfies TodoistTool 61 | 62 | function generateTextContent({ 63 | projects, 64 | args, 65 | }: { 66 | projects: (PersonalProject | WorkspaceProject)[] 67 | args: z.infer> 68 | }) { 69 | const totalRequested = args.projects.length 70 | const actuallyUpdated = projects.length 71 | const skipped = totalRequested - actuallyUpdated 72 | 73 | const count = projects.length 74 | const projectList = projects.map((project) => `• ${project.name} (id=${project.id})`).join('\n') 75 | 76 | let summary = `Updated ${count} project${count === 1 ? '' : 's'}` 77 | if (skipped > 0) { 78 | summary += ` (${skipped} skipped - no changes)` 79 | } 80 | 81 | if (count > 0) { 82 | summary += `:\n${projectList}` 83 | } 84 | 85 | // Context-aware next steps for updated projects 86 | const nextSteps: string[] = [] 87 | 88 | if (projects.length > 0) { 89 | if (count === 1) { 90 | const project = projects[0] 91 | if (project) { 92 | nextSteps.push( 93 | `Use ${GET_OVERVIEW} with projectId=${project.id} to see project structure`, 94 | ) 95 | nextSteps.push( 96 | `Use ${FIND_TASKS} with projectId=${project.id} to review existing tasks`, 97 | ) 98 | } 99 | } else { 100 | nextSteps.push(`Use ${FIND_PROJECTS} to see all projects with updated names`) 101 | nextSteps.push(`Use ${GET_OVERVIEW} to see updated project hierarchy`) 102 | } 103 | } else { 104 | nextSteps.push(`Use ${FIND_PROJECTS} to see current projects`) 105 | } 106 | 107 | const next = formatNextSteps(nextSteps) 108 | return `${summary}\n${next}` 109 | } 110 | 111 | function hasUpdatesToMake({ id, ...otherUpdateArgs }: ProjectUpdate) { 112 | return Object.keys(otherUpdateArgs).length > 0 113 | } 114 | 115 | export { updateProjects } 116 | -------------------------------------------------------------------------------- /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 { formatNextSteps } from '../utils/response-builders.js' 6 | import { ToolNames } from '../utils/tool-names.js' 7 | 8 | const { FIND_TASKS, GET_OVERVIEW, FIND_SECTIONS } = ToolNames 9 | 10 | const SectionUpdateSchema = z.object({ 11 | id: z.string().min(1).describe('The ID of the section to update.'), 12 | name: z.string().min(1).describe('The new name of the section.'), 13 | }) 14 | 15 | const ArgsSchema = { 16 | sections: z.array(SectionUpdateSchema).min(1).describe('The sections to update.'), 17 | } 18 | 19 | const updateSections = { 20 | name: ToolNames.UPDATE_SECTIONS, 21 | description: 'Update multiple existing sections with new values.', 22 | parameters: ArgsSchema, 23 | async execute({ sections }, client) { 24 | const updatedSections = await Promise.all( 25 | sections.map((section) => client.updateSection(section.id, { name: section.name })), 26 | ) 27 | 28 | const textContent = generateTextContent({ 29 | sections: updatedSections, 30 | }) 31 | 32 | return getToolOutput({ 33 | textContent, 34 | structuredContent: { 35 | sections: updatedSections, 36 | totalCount: updatedSections.length, 37 | updatedSectionIds: updatedSections.map((section) => section.id), 38 | }, 39 | }) 40 | }, 41 | } satisfies TodoistTool 42 | 43 | function generateNextSteps(sections: Section[]): string[] { 44 | // Handle empty sections first (early return) 45 | if (sections.length === 0) { 46 | return [`Use ${FIND_SECTIONS} to see current sections`] 47 | } 48 | 49 | // Handle single section case 50 | if (sections.length === 1) { 51 | const section = sections[0] 52 | if (!section) return [] 53 | 54 | return [ 55 | `Use ${FIND_TASKS} with sectionId=${section.id} to see existing tasks`, 56 | `Use ${GET_OVERVIEW} with projectId=${section.projectId} to see project structure`, 57 | 'Consider updating task descriptions if section purpose changed', 58 | ] 59 | } 60 | 61 | // Handle multiple sections case 62 | const projectIds = [...new Set(sections.map((s) => s.projectId))] 63 | const steps = [`Use ${FIND_SECTIONS} to see all sections with updated names`] 64 | 65 | if (projectIds.length === 1) { 66 | steps.push( 67 | `Use ${GET_OVERVIEW} with projectId=${projectIds[0]} to see updated project structure`, 68 | ) 69 | } else { 70 | steps.push(`Use ${GET_OVERVIEW} to see updated project structures`) 71 | } 72 | 73 | steps.push('Consider updating task descriptions if section purposes changed') 74 | return steps 75 | } 76 | 77 | function generateTextContent({ sections }: { sections: Section[] }) { 78 | const count = sections.length 79 | const sectionList = sections 80 | .map((section) => `• ${section.name} (id=${section.id}, projectId=${section.projectId})`) 81 | .join('\n') 82 | 83 | const summary = `Updated ${count} section${count === 1 ? '' : 's'}:\n${sectionList}` 84 | 85 | const nextSteps = generateNextSteps(sections) 86 | 87 | const next = formatNextSteps(nextSteps) 88 | return `${summary}\n${next}` 89 | } 90 | 91 | export { updateSections } 92 | -------------------------------------------------------------------------------- /src/tools/user-info.ts: -------------------------------------------------------------------------------- 1 | import type { TodoistApi } from '@doist/todoist-api-typescript' 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 | 8 | type UserPlan = 'Todoist Free' | 'Todoist Pro' | 'Todoist Business' 9 | 10 | type UserInfoStructured = Record & { 11 | type: 'user_info' 12 | userId: string 13 | fullName: string 14 | timezone: string 15 | currentLocalTime: string 16 | startDay: number 17 | startDayName: string 18 | weekStartDate: string 19 | weekEndDate: string 20 | currentWeekNumber: number 21 | completedToday: number 22 | dailyGoal: number 23 | weeklyGoal: number 24 | email: string 25 | plan: UserPlan 26 | } 27 | 28 | function getUserPlan(user: { isPremium: boolean; businessAccountId?: string | null }): UserPlan { 29 | if (user.businessAccountId) { 30 | return 'Todoist Business' 31 | } 32 | if (user.isPremium) { 33 | return 'Todoist Pro' 34 | } 35 | return 'Todoist Free' 36 | } 37 | 38 | // Helper functions for date and time calculations 39 | function getWeekStartDate(date: Date, startDay: number): Date { 40 | const currentDay = date.getDay() || 7 // Convert Sunday (0) to 7 for ISO format 41 | const daysFromStart = (currentDay - startDay + 7) % 7 42 | const weekStart = new Date(date) 43 | weekStart.setDate(date.getDate() - daysFromStart) 44 | return weekStart 45 | } 46 | 47 | function getWeekEndDate(weekStart: Date): Date { 48 | const weekEnd = new Date(weekStart) 49 | weekEnd.setDate(weekStart.getDate() + 6) 50 | return weekEnd 51 | } 52 | 53 | function getWeekNumber(date: Date): number { 54 | const firstDayOfYear = new Date(date.getFullYear(), 0, 1) 55 | const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000 56 | return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7) 57 | } 58 | 59 | function getDayName(dayNumber: number): string { 60 | const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] 61 | // Convert ISO day number (1=Monday, 7=Sunday) to array index (0=Sunday, 6=Saturday) 62 | const index = dayNumber === 7 ? 0 : dayNumber 63 | return days[index] ?? 'Unknown' 64 | } 65 | 66 | function formatDate(date: Date): string { 67 | return date.toISOString().split('T')[0] ?? '' 68 | } 69 | 70 | function isValidTimezone(timezone: string): boolean { 71 | try { 72 | // Test if the timezone is valid by attempting to format a date with it 73 | new Intl.DateTimeFormat('en-US', { timeZone: timezone }) 74 | return true 75 | } catch { 76 | return false 77 | } 78 | } 79 | 80 | function getSafeTimezone(timezone: string): string { 81 | return isValidTimezone(timezone) ? timezone : 'UTC' 82 | } 83 | 84 | function formatLocalTime(date: Date, timezone: string): string { 85 | const safeTimezone = getSafeTimezone(timezone) 86 | return date.toLocaleString('en-US', { 87 | timeZone: safeTimezone, 88 | year: 'numeric', 89 | month: '2-digit', 90 | day: '2-digit', 91 | hour: '2-digit', 92 | minute: '2-digit', 93 | second: '2-digit', 94 | hour12: false, 95 | }) 96 | } 97 | 98 | async function generateUserInfo( 99 | client: TodoistApi, 100 | ): Promise<{ textContent: string; structuredContent: UserInfoStructured }> { 101 | // Get user information from Todoist API 102 | const user = await client.getUser() 103 | 104 | // Parse timezone from user data and ensure it's valid 105 | const rawTimezone = user.tzInfo?.timezone ?? 'UTC' 106 | const timezone = getSafeTimezone(rawTimezone) 107 | 108 | // Get current time in user's timezone 109 | const now = new Date() 110 | const localTime = formatLocalTime(now, timezone) 111 | 112 | // Calculate week information based on user's start day 113 | const startDay = user.startDay ?? 1 // Default to Monday if not set 114 | const startDayName = getDayName(startDay) 115 | 116 | // Determine user's plan 117 | const plan = getUserPlan(user) 118 | 119 | // Create a date object in user's timezone for accurate week calculations 120 | const userDate = new Date(now.toLocaleString('en-US', { timeZone: timezone })) 121 | const weekStart = getWeekStartDate(userDate, startDay) 122 | const weekEnd = getWeekEndDate(weekStart) 123 | const weekNumber = getWeekNumber(userDate) 124 | 125 | // Generate markdown text content 126 | const lines: string[] = [ 127 | '# User Information', 128 | '', 129 | `**User ID:** ${user.id}`, 130 | `**Full Name:** ${user.fullName}`, 131 | `**Email:** ${user.email}`, 132 | `**Timezone:** ${timezone}`, 133 | `**Current Local Time:** ${localTime}`, 134 | '', 135 | '## Week Settings', 136 | `**Week Start Day:** ${startDayName} (${startDay})`, 137 | `**Current Week:** Week ${weekNumber}`, 138 | `**Week Start Date:** ${formatDate(weekStart)}`, 139 | `**Week End Date:** ${formatDate(weekEnd)}`, 140 | '', 141 | '## Daily Progress', 142 | `**Completed Today:** ${user.completedToday}`, 143 | `**Daily Goal:** ${user.dailyGoal}`, 144 | `**Weekly Goal:** ${user.weeklyGoal}`, 145 | '', 146 | '## Account Info', 147 | `**Plan:** ${plan}`, 148 | ] 149 | 150 | const textContent = lines.join('\n') 151 | 152 | // Generate structured content 153 | const structuredContent: UserInfoStructured = { 154 | type: 'user_info', 155 | userId: user.id, 156 | fullName: user.fullName, 157 | timezone: timezone, 158 | currentLocalTime: localTime, 159 | startDay: startDay, 160 | startDayName: startDayName, 161 | weekStartDate: formatDate(weekStart), 162 | weekEndDate: formatDate(weekEnd), 163 | currentWeekNumber: weekNumber, 164 | completedToday: user.completedToday, 165 | dailyGoal: user.dailyGoal, 166 | weeklyGoal: user.weeklyGoal, 167 | email: user.email, 168 | plan: plan, 169 | } 170 | 171 | return { textContent, structuredContent } 172 | } 173 | 174 | const userInfo = { 175 | name: ToolNames.USER_INFO, 176 | description: 177 | '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).', 178 | parameters: ArgsSchema, 179 | async execute(_args, client) { 180 | const result = await generateUserInfo(client) 181 | 182 | return getToolOutput({ 183 | textContent: result.textContent, 184 | structuredContent: result.structuredContent, 185 | }) 186 | }, 187 | } satisfies TodoistTool 188 | 189 | export { userInfo, type UserInfoStructured } 190 | -------------------------------------------------------------------------------- /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 | } as const 29 | 30 | // UI Display Limits 31 | export const DisplayLimits = { 32 | /** Maximum number of failures to show in detailed error messages */ 33 | MAX_FAILURES_SHOWN: 3, 34 | /** Threshold for suggesting batch operations */ 35 | BATCH_OPERATION_THRESHOLD: 10, 36 | } as const 37 | 38 | // Response Builder Configuration 39 | export const ResponseConfig = { 40 | /** Maximum characters per line in text responses */ 41 | MAX_LINE_LENGTH: 100, 42 | /** Indentation for nested items */ 43 | INDENT_SIZE: 2, 44 | } as const 45 | -------------------------------------------------------------------------------- /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/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 | // General tools 37 | GET_OVERVIEW: 'get-overview', 38 | DELETE_OBJECT: 'delete-object', 39 | USER_INFO: 'user-info', 40 | 41 | // OpenAI MCP tools 42 | SEARCH: 'search', 43 | FETCH: 'fetch', 44 | } as const 45 | 46 | // Type for all tool names 47 | export type ToolName = (typeof ToolNames)[keyof typeof ToolNames] 48 | -------------------------------------------------------------------------------- /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 | "include": ["src"] 36 | } 37 | --------------------------------------------------------------------------------