18 min read

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.

MCP Server Tutorial
Model Context Protocol
Todoist API
TypeScript
Claude
AI Integration
Build MCP Server

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:

  1. Declares a set of tools (functions the AI can call)
  2. Communicates with the AI client over stdio (standard input/output)
  3. 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:


Project setup

Create a new directory and initialize the project:

bash
mkdir mcp-todoist && cd mcp-todoist npm init -y

Install the dependencies:

bash
npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node

Here is what each package does:

  • @modelcontextprotocol/sdk is the official MCP TypeScript SDK. It handles the protocol, tool registration, and transport layer.
  • zod provides runtime input validation. Every tool needs a schema so Claude knows what parameters to send, and Zod validates them before your code runs.
  • typescript and @types/node are 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:

bash
mkdir src

The server skeleton

Create src/index.ts with the basic MCP server structure:

typescript
import { 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, never console.log. The MCP protocol uses stdio for communication, so anything written to stdout would corrupt the protocol messages.
  • The todoistRequest helper 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:

typescript
server.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:

  1. A name ("list_projects"). This is how Claude refers to the tool.
  2. Metadata with a description and inputSchema. The description helps Claude decide when to use the tool. The input schema defines what parameters the tool accepts; this one takes none.
  3. A handler function that executes when Claude calls the tool. It must return an object with a content array containing text or image blocks.

That is it for your first tool. Build and test:

bash
npm 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:

typescript
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 }) => { 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

typescript
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 }) => { 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

typescript
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 }) => { 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

typescript
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 }) => { 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:

typescript
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 }) => { 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:

  1. 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.

  2. 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.

  3. 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:

typescript
server.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.stderr so 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:

bash
npm 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:

typescript
import { 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_comments to read existing comments on a task
  • Add delete_task for cleanup operations
  • Add section support to organize tasks within projects
  • Implement rate limit tracking by reading the X-Ratelimit-Remaining response header and pausing when you approach 450 requests per 15 minutes
  • Add list_labels so 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.