Build a Custom MCP Server: Todoist Example
Step-by-step tutorial for building your first MCP server in TypeScript. Connect Claude to the Todoist API with tool registration and validation.
You want Claude to manage your Todoist tasks. Not through copy-paste, not through some hacky browser extension, but natively: "show me my overdue tasks," "create a task to review the Q3 report by Friday," "mark that done." This tutorial walks you through building an MCP server that makes that happen.
By the end, you will have a working TypeScript MCP server with six tools that let Claude list projects, read tasks, create and update tasks, mark them complete, and add comments. The same patterns apply to any REST API, so once you build this, you can connect Claude to virtually any SaaS platform.
What is MCP?
Model Context Protocol (MCP) is an open standard that lets AI assistants call external tools. Instead of the AI guessing or asking you to look things up, it can directly interact with APIs, databases, and services on your behalf.
An MCP server is a small program that:
- Declares a set of tools (functions the AI can call)
- Communicates with the AI client over stdio (standard input/output)
- Executes API calls when the AI invokes a tool and returns the results
The AI client (Claude Desktop, Claude Code, Cursor) discovers your tools automatically and decides when to use them based on your conversation.
Prerequisites
Before you start, make sure you have:
- Node.js 18+ installed (
node --versionto check) - npm (comes with Node.js)
- A Todoist account with an API token (get yours at https://app.todoist.com/app/settings/integrations/developer)
- Claude Desktop or Claude Code installed for testing
Project setup
Create a new directory and initialize the project:
bashmkdir mcp-todoist && cd mcp-todoist npm init -y
Install the dependencies:
bashnpm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node
Here is what each package does:
@modelcontextprotocol/sdkis the official MCP TypeScript SDK. It handles the protocol, tool registration, and transport layer.zodprovides runtime input validation. Every tool needs a schema so Claude knows what parameters to send, and Zod validates them before your code runs.typescriptand@types/nodeare for type checking and Node.js type definitions.
Create a tsconfig.json:
json{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] }
Update your package.json to add the build script and set the module type:
json{ "type": "module", "scripts": { "build": "tsc", "start": "node dist/index.js" } }
Create the source directory:
bashmkdir src
The server skeleton
Create src/index.ts with the basic MCP server structure:
typescriptimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; // Todoist API configuration const TODOIST_API_BASE = "https://api.todoist.com/rest/v2"; const TODOIST_TOKEN = process.env.TODOIST_API_TOKEN; if (!TODOIST_TOKEN) { process.stderr.write("TODOIST_API_TOKEN environment variable is required\n"); process.exit(1); } // Helper for authenticated Todoist requests async function todoistRequest( path: string, options: RequestInit = {} ): Promise<any> { const url = `${TODOIST_API_BASE}${path}`; const response = await fetch(url, { ...options, headers: { Authorization: `Bearer ${TODOIST_TOKEN}`, "Content-Type": "application/json", ...options.headers, }, }); if (!response.ok) { const body = await response.text(); throw new Error(`Todoist API error ${response.status}: ${body}`); } // Some endpoints (like close task) return 204 with no body if (response.status === 204) return null; return response.json(); } // Create the MCP server const server = new McpServer({ name: "mcp-todoist", version: "1.0.0", }); // Tools will be registered here // Connect via stdio transport async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((err) => { process.stderr.write(`Fatal error: ${err.message}\n`); process.exit(1); });
A few things to notice:
- Errors go to
process.stderr, neverconsole.log. The MCP protocol uses stdio for communication, so anything written to stdout would corrupt the protocol messages. - The
todoistRequesthelper handles authentication and error responses in one place so you don't repeat yourself in every tool. - The token comes from an environment variable. Never hardcode API tokens.
Your first tool: list_projects
Let's register the simplest tool first. Add this between the server creation and the main() function:
typescriptserver.registerTool( "list_projects", { description: "List all projects in the user's Todoist account. Returns project names, IDs, and colors.", inputSchema: {}, }, async () => { const projects = await todoistRequest("/projects"); return { content: [ { type: "text" as const, text: JSON.stringify(projects, null, 2), }, ], }; } );
Every tool registration has three parts:
- A name (
"list_projects"). This is how Claude refers to the tool. - Metadata with a
descriptionandinputSchema. The description helps Claude decide when to use the tool. The input schema defines what parameters the tool accepts; this one takes none. - A handler function that executes when Claude calls the tool. It must return an object with a
contentarray containing text or image blocks.
That is it for your first tool. Build and test:
bashnpm run build
Adding task tools
Now for the tools that make this server genuinely useful.
get_tasks
This tool lists tasks, optionally filtered by project or a search filter:
typescriptserver.registerTool( "get_tasks", { description: "Get active tasks from Todoist. Can filter by project ID or use Todoist filter syntax (e.g. 'overdue', 'today', 'p1').", inputSchema: { project_id: z .string() .optional() .describe("Filter tasks by project ID"), filter: z .string() .optional() .describe( "Todoist filter query, e.g. 'overdue', 'today & p1', '#Work'" ), }, }, async ({ project_id, filter }) => { const params = new URLSearchParams(); if (project_id) params.set("project_id", project_id); if (filter) params.set("filter", filter); const query = params.toString(); const path = `/tasks${query ? `?${query}` : ""}`; const tasks = await todoistRequest(path); // Map priority for readability (API priority is inverted) const formatted = tasks.map((t: any) => ({ id: t.id, content: t.content, description: t.description, priority: t.priority, priorityLabel: ["P4", "P3", "P2", "P1"][t.priority - 1], due: t.due, project_id: t.project_id, labels: t.labels, url: t.url, })); return { content: [ { type: "text" as const, text: JSON.stringify(formatted, null, 2) }, ], }; } );
A gotcha worth knowing: Todoist's API priority values are inverted compared to the UI. API priority 4 means "Priority 1" (the red, highest urgency) in the Todoist app, and API priority 1 means "Priority 4" (no priority). The priorityLabel field in the code above maps this so Claude gives you accurate information.
create_task
typescriptserver.registerTool( "create_task", { description: "Create a new task in Todoist. Supports setting content, description, due dates, priority, labels, and project.", inputSchema: { content: z.string().describe("The task title (required)"), description: z .string() .optional() .describe("Detailed description of the task"), project_id: z .string() .optional() .describe("Project ID to add the task to"), due_string: z .string() .optional() .describe( "Natural language due date, e.g. 'tomorrow', 'every monday', 'Jan 15'" ), due_date: z .string() .optional() .describe("Specific due date in YYYY-MM-DD format"), priority: z .number() .min(1) .max(4) .optional() .describe( "Task priority: 4 = highest (P1 in UI), 3 = high, 2 = medium, 1 = no priority" ), labels: z .array(z.string()) .optional() .describe("Array of label names to apply"), }, }, async ({ content, description, project_id, due_string, due_date, priority, labels }) => { const body: Record<string, any> = { content }; if (description) body.description = description; if (project_id) body.project_id = project_id; if (due_string) body.due_string = due_string; if (due_date) body.due_date = due_date; if (priority) body.priority = priority; if (labels) body.labels = labels; const task = await todoistRequest("/tasks", { method: "POST", body: JSON.stringify(body), headers: { "X-Request-Id": crypto.randomUUID(), }, }); return { content: [ { type: "text" as const, text: `Task created: "${task.content}" (ID: ${task.id})\n${JSON.stringify(task, null, 2)}`, }, ], }; } );
Notice the X-Request-Id header. Todoist uses this for idempotency on mutation endpoints. If a network error causes a retry, sending the same request ID ensures the task is only created once. We use crypto.randomUUID() which is available natively in Node.js 18+.
update_task
typescriptserver.registerTool( "update_task", { description: "Update an existing task's content, description, due date, priority, or labels.", inputSchema: { task_id: z.string().describe("The ID of the task to update"), content: z.string().optional().describe("New task title"), description: z.string().optional().describe("New description"), due_string: z .string() .optional() .describe("New natural language due date"), due_date: z .string() .optional() .describe("New due date in YYYY-MM-DD format"), priority: z .number() .min(1) .max(4) .optional() .describe( "New priority: 4 = highest (P1 in UI), 3 = high, 2 = medium, 1 = no priority" ), labels: z .array(z.string()) .optional() .describe("New set of label names"), }, }, async ({ task_id, content, description, due_string, due_date, priority, labels }) => { const body: Record<string, any> = {}; if (content !== undefined) body.content = content; if (description !== undefined) body.description = description; if (due_string !== undefined) body.due_string = due_string; if (due_date !== undefined) body.due_date = due_date; if (priority !== undefined) body.priority = priority; if (labels !== undefined) body.labels = labels; const task = await todoistRequest(`/tasks/${task_id}`, { method: "POST", body: JSON.stringify(body), headers: { "X-Request-Id": crypto.randomUUID(), }, }); return { content: [ { type: "text" as const, text: `Task updated: "${task.content}"\n${JSON.stringify(task, null, 2)}`, }, ], }; } );
complete_task
typescriptserver.registerTool( "complete_task", { description: "Mark a task as completed in Todoist.", inputSchema: { task_id: z.string().describe("The ID of the task to complete"), }, }, async ({ task_id }) => { await todoistRequest(`/tasks/${task_id}/close`, { method: "POST", }); return { content: [ { type: "text" as const, text: `Task ${task_id} marked as complete.`, }, ], }; } );
This one is straightforward. The /close endpoint returns 204 No Content, which our helper already handles.
Adding comments
The final tool lets Claude add comments to tasks, useful for logging notes or follow-ups:
typescriptserver.registerTool( "add_comment", { description: "Add a comment to a Todoist task. Useful for adding notes, context, or follow-up information.", inputSchema: { task_id: z.string().describe("The ID of the task to comment on"), content: z .string() .describe("The comment text (supports Markdown)"), }, }, async ({ task_id, content }) => { const comment = await todoistRequest("/comments", { method: "POST", body: JSON.stringify({ task_id, content, }), headers: { "X-Request-Id": crypto.randomUUID(), }, }); return { content: [ { type: "text" as const, text: `Comment added to task ${task_id}:\n${JSON.stringify(comment, null, 2)}`, }, ], }; } );
Why Zod matters for input validation
You might be wondering why we use Zod schemas instead of just accepting any object. Three reasons:
-
Claude reads the schemas. The input schema is sent to Claude as part of the tool definition. Detailed
.describe()annotations help Claude understand what values to send. Without them, Claude has to guess. -
Invalid inputs are caught early. If Claude sends a priority of
5, Zod rejects it before your handler runs. You get a clean error message instead of a confusing API failure downstream. -
TypeScript types are inferred. The handler's parameter types are automatically derived from the Zod schema. No need to write interfaces separately.
For example, this schema:
typescript{ priority: z.number().min(1).max(4).optional() .describe("Task priority: 4 = highest (P1 in UI), 1 = no priority"), }
Tells Claude exactly what range of values is valid, gives it context about the inverted priority mapping, and ensures that any value outside 1-4 is rejected before your code even sees it.
Error handling patterns
Our todoistRequest helper already catches HTTP errors, but tools should handle errors gracefully so Claude can report them to the user instead of crashing. Wrap each handler with try/catch:
typescriptserver.registerTool( "get_tasks", { description: "Get active tasks from Todoist.", inputSchema: { // ... schema as above }, }, async ({ project_id, filter }) => { try { // ... implementation return { content: [ { type: "text" as const, text: JSON.stringify(formatted, null, 2) }, ], }; } catch (error: any) { return { content: [ { type: "text" as const, text: `Error fetching tasks: ${error.message}`, }, ], isError: true, }; } } );
The isError: true flag tells the AI client that the tool call failed. Claude can then explain the error to the user or suggest fixes (like checking the API token).
For production servers, you should also consider:
- Rate limiting: Todoist allows 450 requests per 15 minutes. If you hit the limit, the API returns
429. Your error handler should tell Claude to wait before retrying. - Logging to stderr: Write diagnostic information to
process.stderrso it doesn't interfere with the MCP protocol on stdout. - Token validation: Check that the API token works on startup by making a test request to
/projects.
Testing with Claude Desktop
Build the project:
bashnpm run build
Now configure Claude Desktop to use your server. Open your Claude Desktop configuration file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
Add your server to the mcpServers section:
json{ "mcpServers": { "todoist": { "command": "node", "args": ["/absolute/path/to/mcp-todoist/dist/index.js"], "env": { "TODOIST_API_TOKEN": "your-todoist-api-token-here" } } } }
Replace /absolute/path/to/mcp-todoist/dist/index.js with the actual path to your built file, and paste your Todoist API token.
Restart Claude Desktop. You should see a hammer icon indicating that MCP tools are available. Try these prompts:
- "Show me all my Todoist projects"
- "What tasks are overdue?"
- "Create a task to review the quarterly report by next Friday, high priority"
- "Mark task [ID] as done"
- "Add a comment to that task saying the review is complete"
If you are using Claude Code instead, add the server to your project's .mcp.json file or your global ~/.claude.json:
json{ "mcpServers": { "todoist": { "command": "node", "args": ["/absolute/path/to/mcp-todoist/dist/index.js"], "env": { "TODOIST_API_TOKEN": "your-todoist-api-token-here" } } } }
The complete server
Here is the full src/index.ts for reference. Copy this, build, and you have a working Todoist MCP server:
typescriptimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const TODOIST_API_BASE = "https://api.todoist.com/rest/v2"; const TODOIST_TOKEN = process.env.TODOIST_API_TOKEN; if (!TODOIST_TOKEN) { process.stderr.write("TODOIST_API_TOKEN environment variable is required\n"); process.exit(1); } async function todoistRequest( path: string, options: RequestInit = {} ): Promise<any> { const url = `${TODOIST_API_BASE}${path}`; const response = await fetch(url, { ...options, headers: { Authorization: `Bearer ${TODOIST_TOKEN}`, "Content-Type": "application/json", ...options.headers, }, }); if (!response.ok) { const body = await response.text(); throw new Error(`Todoist API error ${response.status}: ${body}`); } if (response.status === 204) return null; return response.json(); } const server = new McpServer({ name: "mcp-todoist", version: "1.0.0", }); // Tool: list_projects server.registerTool( "list_projects", { description: "List all projects in the user's Todoist account. Returns project names, IDs, and colors.", inputSchema: {}, }, async () => { try { const projects = await todoistRequest("/projects"); return { content: [ { type: "text" as const, text: JSON.stringify(projects, null, 2) }, ], }; } catch (error: any) { return { content: [ { type: "text" as const, text: `Error listing projects: ${error.message}` }, ], isError: true, }; } } ); // Tool: get_tasks server.registerTool( "get_tasks", { description: "Get active tasks from Todoist. Can filter by project ID or use Todoist filter syntax (e.g. 'overdue', 'today', 'p1').", inputSchema: { project_id: z.string().optional().describe("Filter tasks by project ID"), filter: z .string() .optional() .describe("Todoist filter query, e.g. 'overdue', 'today & p1', '#Work'"), }, }, async ({ project_id, filter }) => { try { const params = new URLSearchParams(); if (project_id) params.set("project_id", project_id); if (filter) params.set("filter", filter); const query = params.toString(); const path = `/tasks${query ? `?${query}` : ""}`; const tasks = await todoistRequest(path); const formatted = tasks.map((t: any) => ({ id: t.id, content: t.content, description: t.description, priority: t.priority, priorityLabel: ["P4", "P3", "P2", "P1"][t.priority - 1], due: t.due, project_id: t.project_id, labels: t.labels, url: t.url, })); return { content: [ { type: "text" as const, text: JSON.stringify(formatted, null, 2) }, ], }; } catch (error: any) { return { content: [ { type: "text" as const, text: `Error fetching tasks: ${error.message}` }, ], isError: true, }; } } ); // Tool: create_task server.registerTool( "create_task", { description: "Create a new task in Todoist. Supports setting content, description, due dates, priority, labels, and project.", inputSchema: { content: z.string().describe("The task title (required)"), description: z.string().optional().describe("Detailed description of the task"), project_id: z.string().optional().describe("Project ID to add the task to"), due_string: z .string() .optional() .describe("Natural language due date, e.g. 'tomorrow', 'every monday', 'Jan 15'"), due_date: z.string().optional().describe("Specific due date in YYYY-MM-DD format"), priority: z .number() .min(1) .max(4) .optional() .describe("Task priority: 4 = highest (P1 in UI), 3 = high, 2 = medium, 1 = no priority"), labels: z.array(z.string()).optional().describe("Array of label names to apply"), }, }, async ({ content, description, project_id, due_string, due_date, priority, labels }) => { try { const body: Record<string, any> = { content }; if (description) body.description = description; if (project_id) body.project_id = project_id; if (due_string) body.due_string = due_string; if (due_date) body.due_date = due_date; if (priority) body.priority = priority; if (labels) body.labels = labels; const task = await todoistRequest("/tasks", { method: "POST", body: JSON.stringify(body), headers: { "X-Request-Id": crypto.randomUUID() }, }); return { content: [ { type: "text" as const, text: `Task created: "${task.content}" (ID: ${task.id})\n${JSON.stringify(task, null, 2)}`, }, ], }; } catch (error: any) { return { content: [ { type: "text" as const, text: `Error creating task: ${error.message}` }, ], isError: true, }; } } ); // Tool: update_task server.registerTool( "update_task", { description: "Update an existing task's content, description, due date, priority, or labels.", inputSchema: { task_id: z.string().describe("The ID of the task to update"), content: z.string().optional().describe("New task title"), description: z.string().optional().describe("New description"), due_string: z.string().optional().describe("New natural language due date"), due_date: z.string().optional().describe("New due date in YYYY-MM-DD format"), priority: z .number() .min(1) .max(4) .optional() .describe("New priority: 4 = highest (P1 in UI), 3 = high, 2 = medium, 1 = no priority"), labels: z.array(z.string()).optional().describe("New set of label names"), }, }, async ({ task_id, content, description, due_string, due_date, priority, labels }) => { try { const body: Record<string, any> = {}; if (content !== undefined) body.content = content; if (description !== undefined) body.description = description; if (due_string !== undefined) body.due_string = due_string; if (due_date !== undefined) body.due_date = due_date; if (priority !== undefined) body.priority = priority; if (labels !== undefined) body.labels = labels; const task = await todoistRequest(`/tasks/${task_id}`, { method: "POST", body: JSON.stringify(body), headers: { "X-Request-Id": crypto.randomUUID() }, }); return { content: [ { type: "text" as const, text: `Task updated: "${task.content}"\n${JSON.stringify(task, null, 2)}`, }, ], }; } catch (error: any) { return { content: [ { type: "text" as const, text: `Error updating task: ${error.message}` }, ], isError: true, }; } } ); // Tool: complete_task server.registerTool( "complete_task", { description: "Mark a task as completed in Todoist.", inputSchema: { task_id: z.string().describe("The ID of the task to complete"), }, }, async ({ task_id }) => { try { await todoistRequest(`/tasks/${task_id}/close`, { method: "POST", }); return { content: [ { type: "text" as const, text: `Task ${task_id} marked as complete.` }, ], }; } catch (error: any) { return { content: [ { type: "text" as const, text: `Error completing task: ${error.message}` }, ], isError: true, }; } } ); // Tool: add_comment server.registerTool( "add_comment", { description: "Add a comment to a Todoist task. Useful for adding notes, context, or follow-up information.", inputSchema: { task_id: z.string().describe("The ID of the task to comment on"), content: z.string().describe("The comment text (supports Markdown)"), }, }, async ({ task_id, content }) => { try { const comment = await todoistRequest("/comments", { method: "POST", body: JSON.stringify({ task_id, content }), headers: { "X-Request-Id": crypto.randomUUID() }, }); return { content: [ { type: "text" as const, text: `Comment added to task ${task_id}:\n${JSON.stringify(comment, null, 2)}`, }, ], }; } catch (error: any) { return { content: [ { type: "text" as const, text: `Error adding comment: ${error.message}` }, ], isError: true, }; } } ); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((err) => { process.stderr.write(`Fatal error: ${err.message}\n`); process.exit(1); });
Next steps
Once your server is running, here are some ideas to extend it:
- Add
get_commentsto read existing comments on a task - Add
delete_taskfor cleanup operations - Add section support to organize tasks within projects
- Implement rate limit tracking by reading the
X-Ratelimit-Remainingresponse header and pausing when you approach 450 requests per 15 minutes - Add
list_labelsso Claude can discover available labels before creating tasks - Package it as an npm module so others can install it with
npx
The patterns you learned here, tool registration, Zod schemas, stdio transport, error handling, apply to any API. Swap the Todoist endpoints for Notion, Linear, Jira, Stripe, or your own internal API, and you have a new MCP server.
Need a production MCP server?
Building a tutorial server is one thing. Shipping a production-grade MCP server with multi-account support, comprehensive error handling, and proper testing is another. Building something like this for your team? Get in touch.