#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { TodoistApi } from "@doist/todoist-api-typescript";
import { z } from "zod";
// Types from Todoist API
type TodoistTask = {
id: string;
content: string;
description: string;
isCompleted: boolean;
labels: string[];
priority: number;
due?: {
date: string;
datetime?: string;
string: string;
timezone?: string;
recurring: boolean;
};
projectId: string;
url: string;
createdAt: string;
updatedAt: string;
};
type TodoistProject = {
id: string;
name: string;
color: string;
commentCount: number;
order: number;
isShared: boolean;
isFavorite: boolean;
isInboxProject: boolean;
isTeamInbox: boolean;
url: string;
viewStyle: string;
parentId?: string;
};
class TodoistMCPServer {
private server: McpServer;
private todoistApi: TodoistApi;
constructor() {
this.server = new McpServer({
name: "todoist-mcp-server",
version: "1.0.0",
});
// Get API token from environment
const token = process.env.TODOIST_API_TOKEN;
if (!token) {
console.error("TODOIST_API_TOKEN environment variable is required");
process.exit(1);
}
this.todoistApi = new TodoistApi(token);
this.setupTools();
}
private setupTools() {
// Project CRUD Operations
this.setupProjectTools();
// Task CRUD Operations
this.setupTaskTools();
// Subtask Operations
this.setupSubtaskTools();
}
private setupProjectTools() {
// Create Project
this.server.registerTool(
"create_project",
{
title: "Create Project",
description: "Create a new project in Todoist",
inputSchema: {
name: z.string().describe("Project name"),
color: z.string().optional().describe("Project color"),
viewStyle: z.enum(["list", "board"]).optional().describe("View style"),
parentId: z.string().optional().describe("Parent project ID for sub-projects"),
isFavorite: z.boolean().optional().describe("Mark project as favorite"),
},
},
async ({ name, color, viewStyle, parentId, isFavorite }) => {
try {
const projectData: any = { name };
if (color) projectData.color = color;
if (viewStyle) projectData.viewStyle = viewStyle;
if (parentId) projectData.parentId = parentId;
if (isFavorite) projectData.isFavorite = isFavorite;
const project = await this.todoistApi.addProject(projectData);
return {
content: [
{
type: "text",
text: JSON.stringify(project, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error creating project: ${error}`,
},
],
isError: true,
};
}
}
);
// Get Project
this.server.registerTool(
"get_project",
{
title: "Get Project",
description: "Get a project by ID",
inputSchema: {
projectId: z.string().describe("Project ID"),
},
},
async ({ projectId }) => {
try {
const project = await this.todoistApi.getProject(projectId);
return {
content: [
{
type: "text",
text: JSON.stringify(project, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error getting project: ${error}`,
},
],
isError: true,
};
}
}
);
// List Projects
this.server.registerTool(
"list_projects",
{
title: "List Projects",
description: "List all projects",
inputSchema: {},
},
async () => {
try {
const response = await this.todoistApi.getProjects();
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error listing projects: ${error}`,
},
],
isError: true,
};
}
}
);
// Update Project
this.server.registerTool(
"update_project",
{
title: "Update Project",
description: "Update a project",
inputSchema: {
projectId: z.string().describe("Project ID"),
name: z.string().optional().describe("New project name"),
color: z.string().optional().describe("New project color"),
viewStyle: z.enum(["list", "board"]).optional().describe("New view style"),
isFavorite: z.boolean().optional().describe("Mark project as favorite"),
},
},
async ({ projectId, name, color, viewStyle, isFavorite }) => {
try {
const updateData: any = {};
if (name) updateData.name = name;
if (color) updateData.color = color;
if (viewStyle) updateData.viewStyle = viewStyle;
if (isFavorite !== undefined) updateData.isFavorite = isFavorite;
if (Object.keys(updateData).length === 0) {
return {
content: [
{
type: "text",
text: "No fields to update",
},
],
isError: true,
};
}
const project = await this.todoistApi.updateProject(projectId, updateData);
return {
content: [
{
type: "text",
text: JSON.stringify(project, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error updating project: ${error}`,
},
],
isError: true,
};
}
}
);
// Delete Project
this.server.registerTool(
"delete_project",
{
title: "Delete Project",
description: "Delete a project",
inputSchema: {
projectId: z.string().describe("Project ID"),
},
},
async ({ projectId }) => {
try {
await this.todoistApi.deleteProject(projectId);
return {
content: [
{
type: "text",
text: "Project deleted successfully",
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error deleting project: ${error}`,
},
],
isError: true,
};
}
}
);
}
private setupTaskTools() {
// Create Task
this.server.registerTool(
"create_task",
{
title: "Create Task",
description: "Create a new task in Todoist",
inputSchema: {
content: z.string().describe("Task content"),
description: z.string().optional().describe("Task description"),
projectId: z.string().optional().describe("Project ID"),
dueString: z.string().optional().describe("Due date in natural language"),
dueDate: z.string().optional().describe("Due date in YYYY-MM-DD format"),
dueDatetime: z.string().optional().describe("Due datetime in RFC3339 format"),
priority: z.number().min(1).max(4).optional().describe("Priority 1-4 (4 is highest)"),
labels: z.array(z.string()).optional().describe("Task labels"),
},
},
async ({ content, description, projectId, dueString, dueDate, dueDatetime, priority, labels }) => {
try {
const taskData: any = { content };
if (description) taskData.description = description;
if (projectId) taskData.projectId = projectId;
if (dueString) taskData.dueString = dueString;
if (dueDate) taskData.dueDate = dueDate;
if (dueDatetime) taskData.dueDatetime = dueDatetime;
if (priority) taskData.priority = priority;
if (labels) taskData.labels = labels;
const task = await this.todoistApi.addTask(taskData);
return {
content: [
{
type: "text",
text: JSON.stringify(task, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error creating task: ${error}`,
},
],
isError: true,
};
}
}
);
// Get Task
this.server.registerTool(
"get_task",
{
title: "Get Task",
description: "Get a task by ID",
inputSchema: {
taskId: z.string().describe("Task ID"),
},
},
async ({ taskId }) => {
try {
const task = await this.todoistApi.getTask(taskId);
return {
content: [
{
type: "text",
text: JSON.stringify(task, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error getting task: ${error}`,
},
],
isError: true,
};
}
}
);
// List Tasks
this.server.registerTool(
"list_tasks",
{
title: "List Tasks",
description: "List tasks with optional filters",
inputSchema: {
projectId: z.string().optional().describe("Filter by project ID"),
label: z.string().optional().describe("Filter by label"),
filter: z.string().optional().describe("Filter expression"),
},
},
async ({ projectId, label, filter }) => {
try {
let response;
if (filter) {
// For filter-based queries, we'll use a simpler approach
const params: any = {};
if (projectId) params.projectId = projectId;
if (label) params.label = label;
response = await this.todoistApi.getTasks(params);
} else {
const params: any = {};
if (projectId) params.projectId = projectId;
if (label) params.label = label;
response = await this.todoistApi.getTasks(params);
}
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error listing tasks: ${error}`,
},
],
isError: true,
};
}
}
);
// Update Task
this.server.registerTool(
"update_task",
{
title: "Update Task",
description: "Update a task",
inputSchema: {
taskId: z.string().describe("Task ID"),
content: z.string().optional().describe("New task content"),
description: z.string().optional().describe("New task description"),
dueString: z.string().optional().describe("New due date in natural language"),
dueDate: z.string().optional().describe("New due date in YYYY-MM-DD format"),
dueDatetime: z.string().optional().describe("New due datetime in RFC3339 format"),
priority: z.number().min(1).max(4).optional().describe("New priority 1-4"),
labels: z.array(z.string()).optional().describe("New task labels"),
},
},
async ({ taskId, content, description, dueString, dueDate, dueDatetime, priority, labels }) => {
try {
const updateData: any = {};
if (content) updateData.content = content;
if (description) updateData.description = description;
if (dueString) updateData.dueString = dueString;
if (dueDate) updateData.dueDate = dueDate;
if (dueDatetime) updateData.dueDatetime = dueDatetime;
if (priority) updateData.priority = priority;
if (labels) updateData.labels = labels;
if (Object.keys(updateData).length === 0) {
return {
content: [
{
type: "text",
text: "No fields to update",
},
],
isError: true,
};
}
const task = await this.todoistApi.updateTask(taskId, updateData);
return {
content: [
{
type: "text",
text: JSON.stringify(task, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error updating task: ${error}`,
},
],
isError: true,
};
}
}
);
// Complete Task
this.server.registerTool(
"complete_task",
{
title: "Complete Task",
description: "Mark a task as completed",
inputSchema: {
taskId: z.string().describe("Task ID"),
},
},
async ({ taskId }) => {
try {
await this.todoistApi.closeTask(taskId);
return {
content: [
{
type: "text",
text: "Task completed successfully",
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error completing task: ${error}`,
},
],
isError: true,
};
}
}
);
// Reopen Task
this.server.registerTool(
"reopen_task",
{
title: "Reopen Task",
description: "Reopen a completed task",
inputSchema: {
taskId: z.string().describe("Task ID"),
},
},
async ({ taskId }) => {
try {
await this.todoistApi.reopenTask(taskId);
return {
content: [
{
type: "text",
text: "Task reopened successfully",
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error reopening task: ${error}`,
},
],
isError: true,
};
}
}
);
// Delete Task
this.server.registerTool(
"delete_task",
{
title: "Delete Task",
description: "Delete a task",
inputSchema: {
taskId: z.string().describe("Task ID"),
},
},
async ({ taskId }) => {
try {
await this.todoistApi.deleteTask(taskId);
return {
content: [
{
type: "text",
text: "Task deleted successfully",
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error deleting task: ${error}`,
},
],
isError: true,
};
}
}
);
}
private setupSubtaskTools() {
// Get Subtasks
this.server.registerTool(
"get_subtasks",
{
title: "Get Subtasks",
description: "Get all subtasks for a parent task",
inputSchema: {
parentId: z.string().describe("Parent task ID"),
},
},
async ({ parentId }) => {
try {
const response = await this.todoistApi.getTasks({});
const subtasks = response.filter((task: any) => task.parentId === parentId);
return {
content: [
{
type: "text",
text: JSON.stringify(subtasks, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error getting subtasks: ${error}`,
},
],
isError: true,
};
}
}
);
// Add Subtask
this.server.registerTool(
"add_subtask",
{
title: "Add Subtask",
description: "Create a new subtask under a parent task",
inputSchema: {
parentId: z.string().describe("Parent task ID"),
content: z.string().describe("Subtask content"),
description: z.string().optional().describe("Subtask description"),
projectId: z.string().optional().describe("Project ID"),
dueString: z.string().optional().describe("Due date in natural language"),
dueDate: z.string().optional().describe("Due date in YYYY-MM-DD format"),
dueDatetime: z.string().optional().describe("Due datetime in RFC3339 format"),
priority: z.number().min(1).max(4).optional().describe("Priority 1-4 (4 is highest)"),
labels: z.array(z.string()).optional().describe("Subtask labels"),
},
},
async ({ parentId, content, description, projectId, dueString, dueDate, dueDatetime, priority, labels }) => {
try {
const taskData: any = { content, parentId };
if (description) taskData.description = description;
if (projectId) taskData.projectId = projectId;
if (dueString) taskData.dueString = dueString;
if (dueDate) taskData.dueDate = dueDate;
if (dueDatetime) taskData.dueDatetime = dueDatetime;
if (priority) taskData.priority = priority;
if (labels) taskData.labels = labels;
const subtask = await this.todoistApi.addTask(taskData);
return {
content: [
{
type: "text",
text: JSON.stringify(subtask, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error creating subtask: ${error}`,
},
],
isError: true,
};
}
}
);
// Move Task to Subtask
this.server.registerTool(
"move_task_to_subtask",
{
title: "Move Task to Subtask",
description: "Move an existing task to become a subtask of another task",
inputSchema: {
taskId: z.string().describe("Task ID to move"),
parentId: z.string().describe("Parent task ID"),
},
},
async ({ taskId, parentId }) => {
try {
// Note: Direct parent assignment via updateTask may not be supported in current API
// This is a simplified implementation that shows the current task state
const task = await this.todoistApi.getTask(taskId);
return {
content: [
{
type: "text",
text: JSON.stringify({
message: "Move task to subtask functionality requires further API integration",
currentTask: task,
parentId: parentId
}, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error moving task to subtask: ${error}`,
},
],
isError: true,
};
}
}
);
// Promote Subtask
this.server.registerTool(
"promote_subtask",
{
title: "Promote Subtask",
description: "Promote a subtask to become a top-level task",
inputSchema: {
taskId: z.string().describe("Subtask ID to promote"),
projectId: z.string().optional().describe("Project ID to move to (if different)"),
},
},
async ({ taskId, projectId }) => {
try {
// Note: Direct parent removal via updateTask may not be supported in current API
// This is a simplified implementation that shows the current task state
const task = await this.todoistApi.getTask(taskId);
return {
content: [
{
type: "text",
text: JSON.stringify({
message: "Promote subtask functionality requires further API integration",
currentTask: task,
targetProjectId: projectId
}, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error promoting subtask: ${error}`,
},
],
isError: true,
};
}
}
);
// Get Task Hierarchy
this.server.registerTool(
"get_task_hierarchy",
{
title: "Get Task Hierarchy",
description: "Get the complete hierarchy for a task (parent and all subtasks)",
inputSchema: {
taskId: z.string().describe("Root task ID"),
},
},
async ({ taskId }) => {
try {
// Get the root task
const rootTask = await this.todoistApi.getTask(taskId);
// Get all subtasks
const allTasks = await this.todoistApi.getTasks({});
const subtasks = allTasks.filter((task: any) => task.parentId === taskId);
const hierarchy = {
rootTask,
subtasks,
};
return {
content: [
{
type: "text",
text: JSON.stringify(hierarchy, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error getting task hierarchy: ${error}`,
},
],
isError: true,
};
}
}
);
}
async start() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
// Handle graceful shutdown
process.on('SIGINT', async () => {
await transport.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
await transport.close();
process.exit(0);
});
}
}
// Start the server
const server = new TodoistMCPServer();
server.start().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});