Todoist Python MCP Server
by Johnxjp
Verified
from dataclasses import dataclass
from datetime import datetime
import logging
import os
import sys
from typing import Literal, Optional
from mcp.server.fastmcp import FastMCP
from todoist_api_python.api import TodoistAPI
TODOIST_API_TOKEN = os.getenv("TODOIST_API_TOKEN")
todoist_api = TodoistAPI(TODOIST_API_TOKEN)
mcp = FastMCP("todoist-server", dependencies=["todoist_api_python"])
logger = logging.getLogger("todoist_server")
@dataclass
class Project:
id: str
name: str
# Abstraction for Todoist project
# https://developer.todoist.com/rest/v2/#get-all-projects
@dataclass
class TodoistProjectResponse:
id: str
name: str
def date_difference(date1: str, date2: str) -> int:
"""Compare two dates in the format 'YYYY-MM-DD'"""
date1 = datetime.strptime(date1, "%Y-%m-%d")
date2 = datetime.strptime(date2, "%Y-%m-%d")
return (date1 - date2).days
@mcp.tool()
def get_projects() -> list[Project]:
"""Get all todo projects. These are like folders for tasks in Todoist"""
try:
projects: TodoistProjectResponse = todoist_api.get_projects()
return [Project(p.id, p.name) for p in projects]
except Exception as e:
return f"Error: Couldn't fetch projects {str(e)}"
def get_project_id_by_name(project_name: str) -> str:
"""Search for a project by name and return its ID"""
projects = get_projects()
for project in projects:
if project.name.lower() == project_name.lower():
return project.id
return None
@mcp.tool()
def get_tasks(
project_id: Optional[str] = None,
project_name: Optional[str] = None,
task_name: Optional[str] = None,
labels: Optional[list[str]] = None,
due_date: Optional[str] = None,
is_overdue: Optional[bool] = None,
priority: Optional[Literal[1, 2, 3, 4]] = None,
limit: Optional[int] = None,
) -> list[str]:
"""
Fetch user's tasks. These can be filtered by project, labels, time, etc. If no filters are provided, all tasks are returned.
Args:
- project_id: The string ID of the project to fetch tasks from. Example '1234567890'
- project_name: Name of the project to fetch tasks from. Example 'Work' or 'Inbox'
- task_name: Filter tasks by name. Example 'Buy groceries'
- labels: List of tags used to filter tasks.
- priority: Filter tasks by priority level. 4 (urgent), 3 (high), 2 (normal), 1 (low)
- due_date: Specific due date in YYYY-MM-DD format. Example '2021-12-31'
- is_overdue: Filter tasks that are overdue.
- limit: Maximum number of tasks to return. Default is all.
"""
tasks = todoist_api.get_tasks()
# How to implement "did you mean this project?" feature?
if project_name:
project_id = get_project_id_by_name(project_name)
if not project_id:
raise ValueError(f"Project '{project_name}' not found")
if project_id:
project_id = project_id.strip('"')
tasks = [t for t in tasks if t.project_id == project_id]
if task_name:
tasks = [t for t in tasks if task_name.lower() in t.content.lower()]
if due_date:
tasks = [t for t in tasks if t.due and t.due["date"] == due_date]
if is_overdue is not None:
now = datetime.today().strftime("%Y-%m-%d")
tasks = [
t for t in tasks if t.due and (date_difference(now, t.due["date"]) < 0) == is_overdue
]
if labels:
for label in labels:
tasks = [t for t in tasks if label.lower() in [l.lower() for l in t.labels]]
if priority:
tasks = [t for t in tasks if t.priority == priority]
return [{"id": t.id, "title": t.content} for t in tasks][:limit]
@mcp.tool()
def delete_task(task_id: str):
"""Delete a task by its ID"""
try:
task_id = task_id.strip('"')
is_success = todoist_api.delete_task(task_id=task_id)
if not is_success:
raise Exception
return "Task deleted successfully"
except Exception as e:
raise Exception(f"Couldn't delete task {str(e)}")
@mcp.tool()
def create_task(
content: str,
description: Optional[str] = None,
project_id: Optional[str] = None,
labels: Optional[list[str]] = None,
priority: Optional[int] = None,
due_date: Optional[str] = None,
section_id: Optional[str] = None,
) -> str:
"""
Create a new task
Args:
- content [str]: Task content. This value may contain markdown-formatted text and hyperlinks. Details on markdown support can be found in the Text Formatting article in the Help Center.
- description [str]: A description for the task. This value may contain markdown-formatted text and hyperlinks. Details on markdown support can be found in the Text Formatting article in the Help Center.
- project_id [str]: The ID of the project to add the task. If none, adds to user's inbox by default.
- labels [list[str]]: The task's labels (a list of names that may represent either personal or shared labels).
- priority [int]: Task priority from 1 (normal) to 4 (urgent).
- due_date [str]: Specific date in YYYY-MM-DD format relative to user’s timezone.
- section_id [str]: The ID of the section to add the task to
Returns:
- task_id: str:
"""
try:
data = {}
if description:
data["description"] = description
if project_id:
data["project_id"] = project_id
if labels:
if isinstance(labels, str):
labels = [labels]
data["labels"] = labels
if priority:
data["priority"] = priority
if due_date:
data["due_date"] = due_date
if section_id:
data["section_id"] = section_id
task = todoist_api.add_task(content, **data)
return task.id
except Exception as e:
raise Exception(f"Couldn't create task {str(e)}")
@mcp.tool()
def update_task(
task_id: str,
content: Optional[str] = None,
description: Optional[str] = None,
labels: Optional[list[str]] = None,
priority: Optional[int] = None,
due_date: Optional[str] = None,
deadline_date: Optional[str] = None,
):
"""
Update an attribute of a task given its ID. Any attribute can be updated.
Args:
- task_id [str | int]: The ID of the task to update. Example '1234567890' or 1234567890
- content [str]: Task content. This value may contain markdown-formatted text and hyperlinks. Details on markdown support can be found in the Text Formatting article in the Help Center.
- description [str]: A description for the task. This value may contain markdown-formatted text and hyperlinks. Details on markdown support can be found in the Text Formatting article in the Help Center.
- labels [list[str]]: The task's labels (a list of names that may represent either personal or shared labels).
- priority [int]: Task priority from 1 (normal) to 4 (urgent).
- due_date [str]: Specific date in YYYY-MM-DD format relative to user’s timezone.
- deadline_date [str]: Specific date in YYYY-MM-DD format relative to user’s timezone.
"""
# Client sometimes struggle to convert int to str
task_id = task_id.strip('"')
try:
data = {}
if content:
data["content"] = content
if description:
data["description"] = description
if labels:
if isinstance(labels, str):
labels = [labels]
data["labels"] = labels
if priority:
data["priority"] = priority
if due_date:
data["due_date"] = due_date
if deadline_date:
data["deadline_date"] = deadline_date
is_success = todoist_api.update_task(task_id=task_id, **data)
if not is_success:
raise Exception
return "Task updated successfully"
except Exception as e:
raise Exception(f"Couldn't update task {str(e)}")
@mcp.tool()
def complete_task(task_id: str) -> str:
"""Mark a task as done"""
try:
task_id = task_id.strip('"')
is_success = todoist_api.close_task(task_id=task_id)
if not is_success:
raise Exception
return "Task closed successfully"
except Exception as e:
raise Exception(f"Couldn't close task {str(e)}")
def main():
"""Entry point for the installed package"""
print("...", file=sys.stderr)
mcp.run(transport="stdio")
if __name__ == "__main__":
main()