18 min de lecture

Comment Construire un Serveur MCP Personnalisé pour Votre SaaS : Exemple avec Todoist

Un tutoriel étape par étape pour construire votre premier serveur MCP en TypeScript, connectant Claude à l'API Todoist avec enregistrement d'outils, validation des entrées et gestion des erreurs.

Tutoriel Serveur MCP
Model Context Protocol
Todoist API
TypeScript
Claude
Intégration IA
Construire Serveur MCP

Comment Construire un Serveur MCP Personnalisé pour Votre SaaS : Exemple avec Todoist

Vous voulez que Claude gère vos tâches Todoist. Pas par copier-coller, pas via une extension de navigateur bricolée, mais nativement : "montre-moi mes tâches en retard", "crée une tâche pour examiner le rapport Q3 d'ici vendredi", "marque ça comme fait". Ce tutoriel vous guide dans la construction d'un serveur MCP qui rend cela possible.

À la fin, vous disposerez d'un serveur MCP fonctionnel en TypeScript avec six outils permettant à Claude de lister les projets, lire les tâches, créer et mettre à jour des tâches, les marquer comme terminées et ajouter des commentaires. Les mêmes patterns s'appliquent à toute API REST, donc une fois celui-ci construit, vous pourrez connecter Claude à pratiquement n'importe quelle plateforme SaaS.


Qu'est-ce que MCP ?

Le Model Context Protocol (MCP) est un standard ouvert qui permet aux assistants IA d'appeler des outils externes. Au lieu que l'IA devine ou vous demande de chercher des informations, elle peut interagir directement avec des API, bases de données et services en votre nom.

Un serveur MCP est un petit programme qui :

  1. Déclare un ensemble d'outils (fonctions que l'IA peut appeler)
  2. Communique avec le client IA via stdio (entrée/sortie standard)
  3. Exécute des appels API quand l'IA invoque un outil et retourne les résultats

Le client IA (Claude Desktop, Claude Code, Cursor) découvre automatiquement vos outils et décide quand les utiliser en fonction de votre conversation.


Prérequis

Avant de commencer, assurez-vous d'avoir :


Configuration du projet

Créez un nouveau répertoire et initialisez le projet :

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

Installez les dépendances :

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

Voici ce que fait chaque paquet :

  • @modelcontextprotocol/sdk est le SDK officiel MCP en TypeScript. Il gère le protocole, l'enregistrement des outils et la couche de transport.
  • zod fournit la validation des entrées à l'exécution. Chaque outil a besoin d'un schéma pour que Claude sache quels paramètres envoyer, et Zod les valide avant l'exécution de votre code.
  • typescript et @types/node servent à la vérification des types et aux définitions de types Node.js.

Créez un 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}

Mettez à jour votre package.json pour ajouter le script de build et définir le type de module :

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

Créez le répertoire source :

1mkdir src

Le squelette du serveur

Créez src/index.ts avec la structure de base du serveur MCP :

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});

Quelques points à noter :

  • Les erreurs vont vers process.stderr, jamais console.log. Le protocole MCP utilise stdio pour la communication, donc tout ce qui est écrit sur stdout corromprait les messages du protocole.
  • Le helper todoistRequest gère l'authentification et les réponses d'erreur en un seul endroit pour éviter la répétition dans chaque outil.
  • Le jeton provient d'une variable d'environnement. Ne codez jamais en dur les jetons API.

Votre premier outil : list_projects

Enregistrons d'abord l'outil le plus simple :

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);

Chaque enregistrement d'outil comporte trois parties :

  1. Un nom ("list_projects"). C'est ainsi que Claude fait référence à l'outil.
  2. Des métadonnées avec une description et un inputSchema. La description aide Claude à décider quand utiliser l'outil. Le schéma d'entrée définit les paramètres acceptés ; ici, il n'y en a aucun.
  3. Une fonction de gestion qui s'exécute quand Claude appelle l'outil. Elle doit retourner un objet avec un tableau content contenant des blocs texte ou image.

Compilez et testez :

1npm run build

Ajout des outils de tâches

get_tasks

Cet outil liste les tâches, avec filtrage optionnel par projet ou filtre de recherche :

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.string().optional().describe("Filter tasks by project ID"), 8 filter: z 9 .string() 10 .optional() 11 .describe("Todoist filter query, e.g. 'overdue', 'today & p1', '#Work'"), 12 }, 13 }, 14 async ({ project_id, filter }) => { 15 const params = new URLSearchParams(); 16 if (project_id) params.set("project_id", project_id); 17 if (filter) params.set("filter", filter); 18 19 const query = params.toString(); 20 const path = `/tasks${query ? `?${query}` : ""}`; 21 const tasks = await todoistRequest(path); 22 23 const formatted = tasks.map((t: any) => ({ 24 id: t.id, 25 content: t.content, 26 description: t.description, 27 priority: t.priority, 28 priorityLabel: ["P4", "P3", "P2", "P1"][t.priority - 1], 29 due: t.due, 30 project_id: t.project_id, 31 labels: t.labels, 32 url: t.url, 33 })); 34 35 return { 36 content: [ 37 { type: "text" as const, text: JSON.stringify(formatted, null, 2) }, 38 ], 39 }; 40 } 41);

Un piège à connaître : Les valeurs de priorité de l'API Todoist sont inversées par rapport à l'interface utilisateur. La priorité API 4 signifie "Priorité 1" (le rouge, la plus urgente) dans l'application Todoist, et la priorité API 1 signifie "Priorité 4" (pas de priorité). Le champ priorityLabel dans le code ci-dessus fait cette correspondance pour que Claude vous donne des informations précises.

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.string().optional().describe("Detailed description of the task"), 9 project_id: z.string().optional().describe("Project ID to add the task to"), 10 due_string: z 11 .string() 12 .optional() 13 .describe("Natural language due date, e.g. 'tomorrow', 'every monday', 'Jan 15'"), 14 due_date: z.string().optional().describe("Specific due date in YYYY-MM-DD format"), 15 priority: z 16 .number() 17 .min(1) 18 .max(4) 19 .optional() 20 .describe("Task priority: 4 = highest (P1 in UI), 3 = high, 2 = medium, 1 = no priority"), 21 labels: z.array(z.string()).optional().describe("Array of label names to apply"), 22 }, 23 }, 24 async ({ content, description, project_id, due_string, due_date, priority, labels }) => { 25 const body: Record<string, any> = { content }; 26 if (description) body.description = description; 27 if (project_id) body.project_id = project_id; 28 if (due_string) body.due_string = due_string; 29 if (due_date) body.due_date = due_date; 30 if (priority) body.priority = priority; 31 if (labels) body.labels = labels; 32 33 const task = await todoistRequest("/tasks", { 34 method: "POST", 35 body: JSON.stringify(body), 36 headers: { "X-Request-Id": crypto.randomUUID() }, 37 }); 38 39 return { 40 content: [ 41 { 42 type: "text" as const, 43 text: `Task created: "${task.content}" (ID: ${task.id})\n${JSON.stringify(task, null, 2)}`, 44 }, 45 ], 46 }; 47 } 48);

Notez l'en-tête X-Request-Id. Todoist l'utilise pour l'idempotence sur les endpoints de mutation. Si une erreur réseau provoque une nouvelle tentative, envoyer le même identifiant de requête garantit que la tâche n'est créée qu'une seule fois. Nous utilisons crypto.randomUUID() qui est disponible nativement dans Node.js 18+.

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`, { method: "POST" }); 11 return { 12 content: [ 13 { type: "text" as const, text: `Task ${task_id} marked as complete.` }, 14 ], 15 }; 16 } 17);

Pourquoi Zod est important pour la validation des entrées

Vous vous demandez peut-être pourquoi nous utilisons des schémas Zod au lieu d'accepter n'importe quel objet. Trois raisons :

  1. Claude lit les schémas. Le schéma d'entrée est envoyé à Claude dans la définition de l'outil. Les annotations .describe() détaillées aident Claude à comprendre quelles valeurs envoyer.

  2. Les entrées invalides sont interceptées tôt. Si Claude envoie une priorité de 5, Zod la rejette avant l'exécution de votre handler.

  3. Les types TypeScript sont inférés. Les types des paramètres du handler sont automatiquement dérivés du schéma Zod.


Patterns de gestion des erreurs

Le helper todoistRequest intercepte déjà les erreurs HTTP, mais les outils doivent gérer les erreurs élégamment pour que Claude puisse les signaler à l'utilisateur plutôt que de planter. Enveloppez chaque handler avec try/catch :

1try { 2 // ... implémentation 3 return { content: [{ type: "text" as const, text: result }] }; 4} catch (error: any) { 5 return { 6 content: [ 7 { type: "text" as const, text: `Error: ${error.message}` }, 8 ], 9 isError: true, 10 }; 11}

Le flag isError: true indique au client IA que l'appel d'outil a échoué. Claude peut alors expliquer l'erreur à l'utilisateur ou suggérer des corrections.


Test avec Claude Desktop

Compilez le projet :

1npm run build

Maintenant configurez Claude Desktop pour utiliser votre serveur. Ouvrez le fichier de configuration de Claude Desktop :

  • macOS : ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows : %APPDATA%\Claude\claude_desktop_config.json
  • Linux : ~/.config/Claude/claude_desktop_config.json

Ajoutez votre serveur à la section mcpServers :

1{ 2 "mcpServers": { 3 "todoist": { 4 "command": "node", 5 "args": ["/chemin/absolu/vers/mcp-todoist/dist/index.js"], 6 "env": { 7 "TODOIST_API_TOKEN": "votre-jeton-api-todoist-ici" 8 } 9 } 10 } 11}

Remplacez /chemin/absolu/vers/mcp-todoist/dist/index.js par le chemin réel vers votre fichier compilé, et collez votre jeton API Todoist.

Redémarrez Claude Desktop. Vous devriez voir une icône de marteau indiquant que les outils MCP sont disponibles. Essayez ces prompts :

  • "Montre-moi tous mes projets Todoist"
  • "Quelles tâches sont en retard ?"
  • "Crée une tâche pour examiner le rapport trimestriel d'ici vendredi prochain, haute priorité"
  • "Marque la tâche [ID] comme terminée"
  • "Ajoute un commentaire à cette tâche disant que la revue est terminée"

Si vous utilisez Claude Code, ajoutez le serveur au fichier .mcp.json de votre projet ou à votre ~/.claude.json global.


Prochaines étapes

Une fois votre serveur en marche, voici quelques idées pour l'étendre :

  • Ajoutez get_comments pour lire les commentaires existants sur une tâche
  • Ajoutez delete_task pour les opérations de nettoyage
  • Ajoutez le support des sections pour organiser les tâches au sein des projets
  • Implémentez le suivi de la limite de taux en lisant l'en-tête de réponse X-Ratelimit-Remaining
  • Ajoutez list_labels pour que Claude puisse découvrir les étiquettes disponibles avant de créer des tâches
  • Empaquetez-le comme module npm pour que d'autres puissent l'installer avec npx

Les patterns que vous avez appris ici, enregistrement d'outils, schémas Zod, transport stdio, gestion des erreurs, s'appliquent à n'importe quelle API. Remplacez les endpoints Todoist par Notion, Linear, Jira, Stripe ou votre propre API interne, et vous aurez un nouveau serveur MCP.


Besoin d'un serveur MCP pour la production ?

Construire un serveur tutoriel est une chose. Livrer un serveur MCP de qualité production avec le support multi-comptes, une gestion complète des erreurs et des tests appropriés en est une autre. Si vous avez besoin d'un serveur MCP personnalisé construit pour votre équipe ou produit, je peux vous aider.

© 2026 Abdelbaki Berkati. Tous droits réservés.