18 min read

How to Build a Custom MCP Server for Your SaaS: A Todoist Example

A step-by-step tutorial for building your first MCP server in TypeScript, connecting Claude to the Todoist API with tool registration, input validation, and error handling.

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

How to Build a Custom MCP Server for Your SaaS: A Todoist Example

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:

1mkdir mcp-todoist && cd mcp-todoist 2npm init -y

Install the dependencies:

1npm install @modelcontextprotocol/sdk zod 2npm 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:

1{ 2 "compilerOptions": { 3 "target": "ES2022", 4 "module": "Node16", 5 "moduleResolution": "Node16", 6 "outDir": "./dist", 7 "rootDir": "./src", 8 "strict": true, 9 "esModuleInterop": true, 10 "skipLibCheck": true 11 }, 12 "include": ["src"] 13}

Update your package.json to add the build script and set the module type:

1{ 2 "type": "module", 3 "scripts": { 4 "build": "tsc", 5 "start": "node dist/index.js" 6 } 7}

Create the source directory:

1mkdir src

The server skeleton

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

1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3import { z } from "zod"; 4 5// Todoist API configuration 6const TODOIST_API_BASE = "https://api.todoist.com/rest/v2"; 7const TODOIST_TOKEN = process.env.TODOIST_API_TOKEN; 8 9if (!TODOIST_TOKEN) { 10 process.stderr.write("TODOIST_API_TOKEN environment variable is required\n"); 11 process.exit(1); 12} 13 14// Helper for authenticated Todoist requests 15async function todoistRequest( 16 path: string, 17 options: RequestInit = {} 18): Promise<any> { 19 const url = `${TODOIST_API_BASE}${path}`; 20 const response = await fetch(url, { 21 ...options, 22 headers: { 23 Authorization: `Bearer ${TODOIST_TOKEN}`, 24 "Content-Type": "application/json", 25 ...options.headers, 26 }, 27 }); 28 29 if (!response.ok) { 30 const body = await response.text(); 31 throw new Error(`Todoist API error ${response.status}: ${body}`); 32 } 33 34 // Some endpoints (like close task) return 204 with no body 35 if (response.status === 204) return null; 36 return response.json(); 37} 38 39// Create the MCP server 40const server = new McpServer({ 41 name: "mcp-todoist", 42 version: "1.0.0", 43}); 44 45// Tools will be registered here 46 47// Connect via stdio transport 48async function main() { 49 const transport = new StdioServerTransport(); 50 await server.connect(transport); 51} 52 53main().catch((err) => { 54 process.stderr.write(`Fatal error: ${err.message}\n`); 55 process.exit(1); 56});

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:

1server.registerTool( 2 "list_projects", 3 { 4 description: 5 "List all projects in the user's Todoist account. Returns project names, IDs, and colors.", 6 inputSchema: {}, 7 }, 8 async () => { 9 const projects = await todoistRequest("/projects"); 10 return { 11 content: [ 12 { 13 type: "text" as const, 14 text: JSON.stringify(projects, null, 2), 15 }, 16 ], 17 }; 18 } 19);

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:

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

1server.registerTool( 2 "get_tasks", 3 { 4 description: 5 "Get active tasks from Todoist. Can filter by project ID or use Todoist filter syntax (e.g. 'overdue', 'today', 'p1').", 6 inputSchema: { 7 project_id: z 8 .string() 9 .optional() 10 .describe("Filter tasks by project ID"), 11 filter: z 12 .string() 13 .optional() 14 .describe( 15 "Todoist filter query, e.g. 'overdue', 'today & p1', '#Work'" 16 ), 17 }, 18 }, 19 async ({ project_id, filter }) => { 20 const params = new URLSearchParams(); 21 if (project_id) params.set("project_id", project_id); 22 if (filter) params.set("filter", filter); 23 24 const query = params.toString(); 25 const path = `/tasks${query ? `?${query}` : ""}`; 26 const tasks = await todoistRequest(path); 27 28 // Map priority for readability (API priority is inverted) 29 const formatted = tasks.map((t: any) => ({ 30 id: t.id, 31 content: t.content, 32 description: t.description, 33 priority: t.priority, 34 priorityLabel: ["P4", "P3", "P2", "P1"][t.priority - 1], 35 due: t.due, 36 project_id: t.project_id, 37 labels: t.labels, 38 url: t.url, 39 })); 40 41 return { 42 content: [ 43 { type: "text" as const, text: JSON.stringify(formatted, null, 2) }, 44 ], 45 }; 46 } 47);

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

1server.registerTool( 2 "create_task", 3 { 4 description: 5 "Create a new task in Todoist. Supports setting content, description, due dates, priority, labels, and project.", 6 inputSchema: { 7 content: z.string().describe("The task title (required)"), 8 description: z 9 .string() 10 .optional() 11 .describe("Detailed description of the task"), 12 project_id: z 13 .string() 14 .optional() 15 .describe("Project ID to add the task to"), 16 due_string: z 17 .string() 18 .optional() 19 .describe( 20 "Natural language due date, e.g. 'tomorrow', 'every monday', 'Jan 15'" 21 ), 22 due_date: z 23 .string() 24 .optional() 25 .describe("Specific due date in YYYY-MM-DD format"), 26 priority: z 27 .number() 28 .min(1) 29 .max(4) 30 .optional() 31 .describe( 32 "Task priority: 4 = highest (P1 in UI), 3 = high, 2 = medium, 1 = no priority" 33 ), 34 labels: z 35 .array(z.string()) 36 .optional() 37 .describe("Array of label names to apply"), 38 }, 39 }, 40 async ({ content, description, project_id, due_string, due_date, priority, labels }) => { 41 const body: Record<string, any> = { content }; 42 if (description) body.description = description; 43 if (project_id) body.project_id = project_id; 44 if (due_string) body.due_string = due_string; 45 if (due_date) body.due_date = due_date; 46 if (priority) body.priority = priority; 47 if (labels) body.labels = labels; 48 49 const task = await todoistRequest("/tasks", { 50 method: "POST", 51 body: JSON.stringify(body), 52 headers: { 53 "X-Request-Id": crypto.randomUUID(), 54 }, 55 }); 56 57 return { 58 content: [ 59 { 60 type: "text" as const, 61 text: `Task created: "${task.content}" (ID: ${task.id})\n${JSON.stringify(task, null, 2)}`, 62 }, 63 ], 64 }; 65 } 66);

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

1server.registerTool( 2 "update_task", 3 { 4 description: "Update an existing task's content, description, due date, priority, or labels.", 5 inputSchema: { 6 task_id: z.string().describe("The ID of the task to update"), 7 content: z.string().optional().describe("New task title"), 8 description: z.string().optional().describe("New description"), 9 due_string: z 10 .string() 11 .optional() 12 .describe("New natural language due date"), 13 due_date: z 14 .string() 15 .optional() 16 .describe("New due date in YYYY-MM-DD format"), 17 priority: z 18 .number() 19 .min(1) 20 .max(4) 21 .optional() 22 .describe( 23 "New priority: 4 = highest (P1 in UI), 3 = high, 2 = medium, 1 = no priority" 24 ), 25 labels: z 26 .array(z.string()) 27 .optional() 28 .describe("New set of label names"), 29 }, 30 }, 31 async ({ task_id, content, description, due_string, due_date, priority, labels }) => { 32 const body: Record<string, any> = {}; 33 if (content !== undefined) body.content = content; 34 if (description !== undefined) body.description = description; 35 if (due_string !== undefined) body.due_string = due_string; 36 if (due_date !== undefined) body.due_date = due_date; 37 if (priority !== undefined) body.priority = priority; 38 if (labels !== undefined) body.labels = labels; 39 40 const task = await todoistRequest(`/tasks/${task_id}`, { 41 method: "POST", 42 body: JSON.stringify(body), 43 headers: { 44 "X-Request-Id": crypto.randomUUID(), 45 }, 46 }); 47 48 return { 49 content: [ 50 { 51 type: "text" as const, 52 text: `Task updated: "${task.content}"\n${JSON.stringify(task, null, 2)}`, 53 }, 54 ], 55 }; 56 } 57);

complete_task

1server.registerTool( 2 "complete_task", 3 { 4 description: "Mark a task as completed in Todoist.", 5 inputSchema: { 6 task_id: z.string().describe("The ID of the task to complete"), 7 }, 8 }, 9 async ({ task_id }) => { 10 await todoistRequest(`/tasks/${task_id}/close`, { 11 method: "POST", 12 }); 13 14 return { 15 content: [ 16 { 17 type: "text" as const, 18 text: `Task ${task_id} marked as complete.`, 19 }, 20 ], 21 }; 22 } 23);

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:

1server.registerTool( 2 "add_comment", 3 { 4 description: 5 "Add a comment to a Todoist task. Useful for adding notes, context, or follow-up information.", 6 inputSchema: { 7 task_id: z.string().describe("The ID of the task to comment on"), 8 content: z 9 .string() 10 .describe("The comment text (supports Markdown)"), 11 }, 12 }, 13 async ({ task_id, content }) => { 14 const comment = await todoistRequest("/comments", { 15 method: "POST", 16 body: JSON.stringify({ 17 task_id, 18 content, 19 }), 20 headers: { 21 "X-Request-Id": crypto.randomUUID(), 22 }, 23 }); 24 25 return { 26 content: [ 27 { 28 type: "text" as const, 29 text: `Comment added to task ${task_id}:\n${JSON.stringify(comment, null, 2)}`, 30 }, 31 ], 32 }; 33 } 34);

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:

1{ 2 priority: z.number().min(1).max(4).optional() 3 .describe("Task priority: 4 = highest (P1 in UI), 1 = no priority"), 4}

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:

1server.registerTool( 2 "get_tasks", 3 { 4 description: "Get active tasks from Todoist.", 5 inputSchema: { 6 // ... schema as above 7 }, 8 }, 9 async ({ project_id, filter }) => { 10 try { 11 // ... implementation 12 return { 13 content: [ 14 { type: "text" as const, text: JSON.stringify(formatted, null, 2) }, 15 ], 16 }; 17 } catch (error: any) { 18 return { 19 content: [ 20 { 21 type: "text" as const, 22 text: `Error fetching tasks: ${error.message}`, 23 }, 24 ], 25 isError: true, 26 }; 27 } 28 } 29);

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:

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

1{ 2 "mcpServers": { 3 "todoist": { 4 "command": "node", 5 "args": ["/absolute/path/to/mcp-todoist/dist/index.js"], 6 "env": { 7 "TODOIST_API_TOKEN": "your-todoist-api-token-here" 8 } 9 } 10 } 11}

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:

1{ 2 "mcpServers": { 3 "todoist": { 4 "command": "node", 5 "args": ["/absolute/path/to/mcp-todoist/dist/index.js"], 6 "env": { 7 "TODOIST_API_TOKEN": "your-todoist-api-token-here" 8 } 9 } 10 } 11}

The complete server

Here is the full src/index.ts for reference. Copy this, build, and you have a working Todoist MCP server:

1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3import { z } from "zod"; 4 5const TODOIST_API_BASE = "https://api.todoist.com/rest/v2"; 6const TODOIST_TOKEN = process.env.TODOIST_API_TOKEN; 7 8if (!TODOIST_TOKEN) { 9 process.stderr.write("TODOIST_API_TOKEN environment variable is required\n"); 10 process.exit(1); 11} 12 13async function todoistRequest( 14 path: string, 15 options: RequestInit = {} 16): Promise<any> { 17 const url = `${TODOIST_API_BASE}${path}`; 18 const response = await fetch(url, { 19 ...options, 20 headers: { 21 Authorization: `Bearer ${TODOIST_TOKEN}`, 22 "Content-Type": "application/json", 23 ...options.headers, 24 }, 25 }); 26 27 if (!response.ok) { 28 const body = await response.text(); 29 throw new Error(`Todoist API error ${response.status}: ${body}`); 30 } 31 32 if (response.status === 204) return null; 33 return response.json(); 34} 35 36const server = new McpServer({ 37 name: "mcp-todoist", 38 version: "1.0.0", 39}); 40 41// Tool: list_projects 42server.registerTool( 43 "list_projects", 44 { 45 description: 46 "List all projects in the user's Todoist account. Returns project names, IDs, and colors.", 47 inputSchema: {}, 48 }, 49 async () => { 50 try { 51 const projects = await todoistRequest("/projects"); 52 return { 53 content: [ 54 { type: "text" as const, text: JSON.stringify(projects, null, 2) }, 55 ], 56 }; 57 } catch (error: any) { 58 return { 59 content: [ 60 { type: "text" as const, text: `Error listing projects: ${error.message}` }, 61 ], 62 isError: true, 63 }; 64 } 65 } 66); 67 68// Tool: get_tasks 69server.registerTool( 70 "get_tasks", 71 { 72 description: 73 "Get active tasks from Todoist. Can filter by project ID or use Todoist filter syntax (e.g. 'overdue', 'today', 'p1').", 74 inputSchema: { 75 project_id: z.string().optional().describe("Filter tasks by project ID"), 76 filter: z 77 .string() 78 .optional() 79 .describe("Todoist filter query, e.g. 'overdue', 'today & p1', '#Work'"), 80 }, 81 }, 82 async ({ project_id, filter }) => { 83 try { 84 const params = new URLSearchParams(); 85 if (project_id) params.set("project_id", project_id); 86 if (filter) params.set("filter", filter); 87 88 const query = params.toString(); 89 const path = `/tasks${query ? `?${query}` : ""}`; 90 const tasks = await todoistRequest(path); 91 92 const formatted = tasks.map((t: any) => ({ 93 id: t.id, 94 content: t.content, 95 description: t.description, 96 priority: t.priority, 97 priorityLabel: ["P4", "P3", "P2", "P1"][t.priority - 1], 98 due: t.due, 99 project_id: t.project_id, 100 labels: t.labels, 101 url: t.url, 102 })); 103 104 return { 105 content: [ 106 { type: "text" as const, text: JSON.stringify(formatted, null, 2) }, 107 ], 108 }; 109 } catch (error: any) { 110 return { 111 content: [ 112 { type: "text" as const, text: `Error fetching tasks: ${error.message}` }, 113 ], 114 isError: true, 115 }; 116 } 117 } 118); 119 120// Tool: create_task 121server.registerTool( 122 "create_task", 123 { 124 description: 125 "Create a new task in Todoist. Supports setting content, description, due dates, priority, labels, and project.", 126 inputSchema: { 127 content: z.string().describe("The task title (required)"), 128 description: z.string().optional().describe("Detailed description of the task"), 129 project_id: z.string().optional().describe("Project ID to add the task to"), 130 due_string: z 131 .string() 132 .optional() 133 .describe("Natural language due date, e.g. 'tomorrow', 'every monday', 'Jan 15'"), 134 due_date: z.string().optional().describe("Specific due date in YYYY-MM-DD format"), 135 priority: z 136 .number() 137 .min(1) 138 .max(4) 139 .optional() 140 .describe("Task priority: 4 = highest (P1 in UI), 3 = high, 2 = medium, 1 = no priority"), 141 labels: z.array(z.string()).optional().describe("Array of label names to apply"), 142 }, 143 }, 144 async ({ content, description, project_id, due_string, due_date, priority, labels }) => { 145 try { 146 const body: Record<string, any> = { content }; 147 if (description) body.description = description; 148 if (project_id) body.project_id = project_id; 149 if (due_string) body.due_string = due_string; 150 if (due_date) body.due_date = due_date; 151 if (priority) body.priority = priority; 152 if (labels) body.labels = labels; 153 154 const task = await todoistRequest("/tasks", { 155 method: "POST", 156 body: JSON.stringify(body), 157 headers: { "X-Request-Id": crypto.randomUUID() }, 158 }); 159 160 return { 161 content: [ 162 { 163 type: "text" as const, 164 text: `Task created: "${task.content}" (ID: ${task.id})\n${JSON.stringify(task, null, 2)}`, 165 }, 166 ], 167 }; 168 } catch (error: any) { 169 return { 170 content: [ 171 { type: "text" as const, text: `Error creating task: ${error.message}` }, 172 ], 173 isError: true, 174 }; 175 } 176 } 177); 178 179// Tool: update_task 180server.registerTool( 181 "update_task", 182 { 183 description: 184 "Update an existing task's content, description, due date, priority, or labels.", 185 inputSchema: { 186 task_id: z.string().describe("The ID of the task to update"), 187 content: z.string().optional().describe("New task title"), 188 description: z.string().optional().describe("New description"), 189 due_string: z.string().optional().describe("New natural language due date"), 190 due_date: z.string().optional().describe("New due date in YYYY-MM-DD format"), 191 priority: z 192 .number() 193 .min(1) 194 .max(4) 195 .optional() 196 .describe("New priority: 4 = highest (P1 in UI), 3 = high, 2 = medium, 1 = no priority"), 197 labels: z.array(z.string()).optional().describe("New set of label names"), 198 }, 199 }, 200 async ({ task_id, content, description, due_string, due_date, priority, labels }) => { 201 try { 202 const body: Record<string, any> = {}; 203 if (content !== undefined) body.content = content; 204 if (description !== undefined) body.description = description; 205 if (due_string !== undefined) body.due_string = due_string; 206 if (due_date !== undefined) body.due_date = due_date; 207 if (priority !== undefined) body.priority = priority; 208 if (labels !== undefined) body.labels = labels; 209 210 const task = await todoistRequest(`/tasks/${task_id}`, { 211 method: "POST", 212 body: JSON.stringify(body), 213 headers: { "X-Request-Id": crypto.randomUUID() }, 214 }); 215 216 return { 217 content: [ 218 { 219 type: "text" as const, 220 text: `Task updated: "${task.content}"\n${JSON.stringify(task, null, 2)}`, 221 }, 222 ], 223 }; 224 } catch (error: any) { 225 return { 226 content: [ 227 { type: "text" as const, text: `Error updating task: ${error.message}` }, 228 ], 229 isError: true, 230 }; 231 } 232 } 233); 234 235// Tool: complete_task 236server.registerTool( 237 "complete_task", 238 { 239 description: "Mark a task as completed in Todoist.", 240 inputSchema: { 241 task_id: z.string().describe("The ID of the task to complete"), 242 }, 243 }, 244 async ({ task_id }) => { 245 try { 246 await todoistRequest(`/tasks/${task_id}/close`, { 247 method: "POST", 248 }); 249 250 return { 251 content: [ 252 { type: "text" as const, text: `Task ${task_id} marked as complete.` }, 253 ], 254 }; 255 } catch (error: any) { 256 return { 257 content: [ 258 { type: "text" as const, text: `Error completing task: ${error.message}` }, 259 ], 260 isError: true, 261 }; 262 } 263 } 264); 265 266// Tool: add_comment 267server.registerTool( 268 "add_comment", 269 { 270 description: 271 "Add a comment to a Todoist task. Useful for adding notes, context, or follow-up information.", 272 inputSchema: { 273 task_id: z.string().describe("The ID of the task to comment on"), 274 content: z.string().describe("The comment text (supports Markdown)"), 275 }, 276 }, 277 async ({ task_id, content }) => { 278 try { 279 const comment = await todoistRequest("/comments", { 280 method: "POST", 281 body: JSON.stringify({ task_id, content }), 282 headers: { "X-Request-Id": crypto.randomUUID() }, 283 }); 284 285 return { 286 content: [ 287 { 288 type: "text" as const, 289 text: `Comment added to task ${task_id}:\n${JSON.stringify(comment, null, 2)}`, 290 }, 291 ], 292 }; 293 } catch (error: any) { 294 return { 295 content: [ 296 { type: "text" as const, text: `Error adding comment: ${error.message}` }, 297 ], 298 isError: true, 299 }; 300 } 301 } 302); 303 304// Start the server 305async function main() { 306 const transport = new StdioServerTransport(); 307 await server.connect(transport); 308} 309 310main().catch((err) => { 311 process.stderr.write(`Fatal error: ${err.message}\n`); 312 process.exit(1); 313});

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. If you need a custom MCP server built for your team or product, I can help.

© 2026 Abdelbaki Berkati. All rights reserved.