Premier serveur MCP pratique
Construire pas à pas un serveur MCP minimal exposant trois outils réalistes, avec validation d'entrées, gestion d'erreurs et tests manuels.
Table des matières
Introduction
Les trois premiers chapitres de cette formation ont posé le cadre : ce qu'est MCP, pourquoi ce protocole s'est imposé face aux intégrations propriétaires, et comment un client (assistant, IDE, agentagentIASystème qui enchaîne des appels d'outils de façon autonome pour atteindre un objectif : il planifie, agit, observe, recommence. Sa fiabilité décroît exponentiellement avec le nombre d'étapes.Voir dans le glossaire) découvre les capacités exposées par un serveur. Il est temps de passer à la pratique : construire un serveur MCPMCPIAModel Context Protocol : protocole permettant de brancher des outils et sources externes (docs, tickets, bases) à un agent LLM via des serveurs dédiés.Voir dans le glossaire minimal mais réaliste, avec trois outilstool callingIACapacité d'un agent ou d'un LLM à invoquer des outils externes (API, calcul, recherche) pendant le raisonnement.Voir dans le glossaire distincts, une validation d'entrées sérieuse, et des messages d'erreur qui aident réellement l'appelant — humain ou modèle — à corriger son tir.
L'objectif n'est pas de produire un exemple jouet qui s'effondre au premier cas limite. Il est de poser des habitudes qui tiennent en production : structure de projet claire, séparation entre déclaration et exécution des outils, gestion d'erreurs homogène. Ce sont ces habitudes, plus que la syntaxe d'un SDK particulier, qui font la différence entre un serveur MCP fiable et un prototype qui casse dès qu'un modèle envoie un paramètre légèrement inattendu.
Ce chapitre suppose que vous avez lu le chapitre 3 sur le cycle de vie d'une session MCP (initialisation, découverte des capacités, appels d'outils). Si ce n'est pas le cas, les notions de
tools/listettools/callutilisées ici ne seront pas évidentes.
Ce que fait concrètement un serveur MCP
Un serveur MCP est un processus qui expose, via un transport normalisé (entrées/sorties standard le plus souvent, ou HTTP en environnement distant), une liste de capacités : des outils (tools), éventuellement des ressources (resources) et des invites (prompts). Le client — un assistant IAintelligence artificielleIAEnsemble des techniques permettant à un programme d'accomplir une tâche qui demanderait de l'intelligence humaine. Le terme couvre aussi bien les systèmes à règles écrites que ceux qui apprennent de données.Voir dans le glossaire, un IDE, un agent d'orchestration — se connecte, demande la liste des outils disponibles, puis les invoque avec des arguments structurés.
Le serveur que nous allons construire expose trois outils représentatifs des cas d'usage les plus courants en entreprise :
read_file— lecture d'un fichier local, avec restriction à un répertoire autorisé.search_documents— recherche texte dans un petit corpus, pour simuler l'accès à une base documentaire.get_customer_order— appel à une API métier factice, représentative d'une intégration vers un CRM ou un ERP.
Ce triptyque couvre les trois familles d'accès qu'un serveur MCP rencontre presque toujours : système de fichiers, recherche, système métier tiers.
Choisir le langage et le scaffold
Le SDK officiel MCP existe en TypeScript, Python, et plusieurs implémentations communautaires dans d'autres langages. Le choix dépend surtout de l'écosystème déjà en place dans l'organisation : une équipe déjà outillée en Python pour la donnée aura intérêt à rester en Python plutôt que d'ajouter un runtime Node à maintenir.
Les exemples de ce chapitre utilisent TypeScript, pour deux raisons pragmatiques : le typage statique aide énormément à la validation des inputs, et une large part des clients MCP (IDE, extensions) tournent déjà dans un environnement Node. La logique reste transposable telle quelle en Python avec le SDK équivalent.
Les SDK officiels fournissent des générateurs de projet (
create-mcp-serverou équivalent). Utilisez-les pour le squelette initial : gestion du transport, boucle de lecture des messages JSON-RPC, négociation de capacités. Ne réécrivez pas cette plomberie à la main — elle est source d'erreurs subtiles sur le framing des messages.
Structure de projet maintenable
Un serveur MCP qui grossit sans organisation devient vite un fichier unique de plusieurs centaines de lignes où déclaration et logique métier sont mélangées. La structure suivante évite ce piège dès le départ :
mon-serveur-mcp/
├── src/
│ ├── index.ts # point d'entrée, câblage du serveur et du transport
│ ├── tools/
│ │ ├── read-file.ts # une déclaration + un handler par outil
│ │ ├── search-documents.ts
│ │ └── get-customer-order.ts
│ ├── validation/
│ │ └── schemas.ts # schémas d'entrée partagés
│ └── errors.ts # types d'erreurs métier homogènes
├── tests/
│ └── manual-client.ts # script de test contre le serveur
└── package.json
Le principe directeur : un fichier par outil, contenant à la fois son schéma d'entrée et son handler. Cela rend chaque outil testable isolément et évite qu'une modification sur l'un affecte accidentellement les autres.
Résistez à la tentation de créer un outil générique
execute_actionqui prendrait un paramètreactionpour dispatcher en interne vers plusieurs comportements. Le modèle appelant choisit ses outils sur la base de leur nom et de leur description : un outil trop générique produit des appels mal formés, car le modèle ne peut pas deviner la bonne valeur d'actionsans exemples explicites dans le promptpromptIAConsigne ou contexte fourni à un modèle de langage pour orienter sa réponse. La qualité du prompt conditionne souvent la qualité du résultat.Voir dans le glossaire.
Déclarer les tools
La déclaration d'un outil MCP comporte trois éléments : un nom stable, une description en langage naturel, et un schéma JSON décrivant les paramètres attendus. C'est cette description qui guide le modèle lors du choix de l'outil — elle mérite autant de soin qu'une signature de fonction publique.
Outil 1 : lecture de fichier
// tools/read-file.ts
export const readFileTool = {
name: "read_file",
description:
"Lit le contenu d'un fichier texte situé dans le répertoire documents/. " +
"Retourne une erreur si le fichier est hors de ce répertoire ou introuvable.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Chemin relatif du fichier, par exemple 'contrats/2026-01.txt'",
},
},
required: ["path"],
},
};
Outil 2 : recherche documentaire
// tools/search-documents.ts
export const searchDocumentsTool = {
name: "search_documents",
description:
"Recherche des documents dont le contenu correspond à une requête texte. " +
"Retourne au maximum 10 résultats, triés par pertinence décroissante.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Termes de recherche" },
limit: {
type: "integer",
description: "Nombre maximal de résultats (1 à 10, défaut 5)",
minimum: 1,
maximum: 10,
},
},
required: ["query"],
},
};
Outil 3 : API métier factice
// tools/get-customer-order.ts
export const getCustomerOrderTool = {
name: "get_customer_order",
description:
"Récupère le statut d'une commande client à partir de son identifiant. " +
"Simule un appel à l'ERP interne.",
inputSchema: {
type: "object",
properties: {
orderId: {
type: "string",
description: "Identifiant de commande au format CMD-NNNNN",
pattern: "^CMD-[0-9]{5}$",
},
},
required: ["orderId"],
},
};
Une description vague comme « accède aux commandes » pousse le modèle à appeler l'outil pour des questions hors sujet. Une description précisant le format attendu de l'identifiant, comme ci-dessus, réduit nettement les appels malformés observés en pratique — c'est un gain de fiabilité obtenu sans une ligne de code supplémentaire.
Valider les inputs
Le schéma JSON déclaré dans le tool n'est qu'une indication pour le client — rien ne garantit qu'un appel réel le respecte. Certains clients valident côté client avant l'envoi, d'autres non ; certains modèles génèrent des arguments qui violent le schéma malgré tout (type incorrect, champ manquant, valeur hors bornes). Le serveur doit donc revalider systématiquement, côté handler, avant toute exécution.
// validation/schemas.ts
import { z } from "zod";
export const readFileInput = z.object({
path: z
.string()
.min(1)
.refine((p) => !p.includes(".."), "chemin invalide : traversée de répertoire interdite"),
});
export const searchDocumentsInput = z.object({
query: z.string().min(2, "la requête doit contenir au moins 2 caractères"),
limit: z.number().int().min(1).max(10).default(5),
});
export const getCustomerOrderInput = z.object({
orderId: z.string().regex(/^CMD-\d{5}$/, "format attendu : CMD-NNNNN"),
});
L'usage d'une bibliothèque de validation (Zod en TypeScript, Pydantic en Python) plutôt que des vérifications manuelles éparpillées apporte deux bénéfices concrets : la cohérence des messages d'erreur entre tous les outils, et l'impossibilité d'oublier un cas limite déjà couvert par le schéma partagé.
Le champ
inputSchemad'une déclaration d'outil MCP n'est pas un mécanisme de sécurité. Il documente l'intention pour le client ; il ne bloque rien côté serveur. Un handler qui lit directementargs.pathsans revalidation s'expose à des traversées de répertoire (../../etc/passwd) ou à des injections si le chemin est ensuite utilisé dans une commande shell.
Ce dernier point mérite une attentionattentionIAMécanisme par lequel un modèle pondère l'importance de chaque token du contexte lorsqu'il en traite un autre, quelle que soit la distance qui les sépare.Voir dans le glossaire particulière pour l'outil de lecture de fichier : la vérification !p.includes("..") est un minimum, mais la pratique robuste consiste à résoudre le chemin absolu obtenu et à vérifier qu'il reste bien sous le répertoire autorisé, plutôt que de se fier à une simple recherche de sous-chaîne.
import { resolve } from "node:path";
const BASE_DIR = resolve("./documents");
function resolveSafePath(relativePath: string): string {
const target = resolve(BASE_DIR, relativePath);
if (!target.startsWith(BASE_DIR)) {
throw new ToolError("PATH_OUTSIDE_ALLOWED_DIR", "Le chemin sort du répertoire autorisé.");
}
return target;
}
Messages d'erreur utiles
Un point souvent négligé : les erreurs renvoyées par un serveur MCP ne sont pas seulement destinées à un humain qui lit un log. Elles sont aussi lues par le modèle qui a émis l'appel, et qui va décider — ou non — de retenter avec des paramètres corrigés. Un message d'erreur vague comme "Error" ou "Invalid input" n'aide personne. Un message qui précise le champ fautif, la contrainte violée, et si possible un exemple de valeur correcte, permet au modèle de s'auto-corriger au tour suivant.
// errors.ts
export class ToolError extends Error {
constructor(public code: string, message: string, public hint?: string) {
super(message);
}
}
// Dans le handler :
try {
const input = getCustomerOrderInput.parse(rawArgs);
// ... exécution
} catch (err) {
return {
isError: true,
content: [
{
type: "text",
text:
"Identifiant de commande invalide. Format attendu : CMD-NNNNN " +
"(ex. CMD-04821). Valeur reçue : " + JSON.stringify(rawArgs.orderId),
},
],
};
}
- Quel champ a échoué la validation.
- Quelle règle n'a pas été respectée, formulée simplement.
- Un exemple de valeur valide, quand c'est possible.
Ce format réduit fortement le nombre d'allers-retours nécessaires avant qu'un appel aboutisse.
Il faut aussi distinguer deux catégories d'erreurs, qui n'appellent pas la même réponse :
| Catégorie | Exemple | Réponse appropriée |
|---|---|---|
| Erreur d'entrée (côté appelant) | identifiant mal formaté, champ manquant | message explicite, isError: true, pas de retry automatique côté serveur |
| Erreur d'exécution (côté serveur) | timeout de l'API métier, fichier verrouillé | message générique + code technique, éventuellement retry avec backoff |
Confondre les deux conduit soit à masquer une vraie panne interne derrière un message pointant à tort vers l'appelant, soit à exposer des détails d'infrastructure inutiles (stack trace, chemin absolu du disque) dans une erreur de saisie banale.
Tester manuellement avec un client
Avant de brancher un assistant complet, il est plus efficace de tester le serveur avec un client minimal, scriptable, qui envoie directement des messages JSON-RPC sur le transport stdio.
// tests/manual-client.ts
import { spawn } from "node:child_process";
const server = spawn("node", ["dist/index.js"]);
function send(method: string, params: unknown) {
const message = { jsonrpc: "2.0", id: Date.now(), method, params };
server.stdin.write(JSON.stringify(message) + "\n");
}
server.stdout.on("data", (chunk) => console.log("<<", chunk.toString()));
send("tools/list", {});
send("tools/call", { name: "get_customer_order", arguments: { orderId: "CMD-04821" } });
send("tools/call", { name: "get_customer_order", arguments: { orderId: "abc" } });
Ce script permet de vérifier trois choses avant toute intégration : que la liste des outils est correctement annoncée, qu'un appel valide produit une réponse exploitable, et qu'un appel invalide produit un message d'erreur effectivement utile — pas une exception non gérée qui ferait planter le processus serveur.
Une exception non interceptée dans un handler d'outil, selon l'implémentation du SDK, peut faire tomber toute la session MCP — pas seulement l'appel en cours. Chaque handler doit donc envelopper sa logique dans un bloc try/catch qui transforme toute erreur en réponse
isError: true, jamais en crash du processus.
Une fois le script manuel satisfaisant, l'étape suivante consiste à connecter un vrai client (extension IDE, assistant en ligne de commande) et à observer, dans ses logs, la séquence réelle d'appels. C'est souvent à ce stade que l'on découvre des descriptions d'outils ambiguës : le modèle choisit le mauvais outil, ou omet un paramètre optionnel qui aurait dû être renseigné.
Schéma du cycle d'un appel d'outil
Checklist avant de considérer le serveur prêt
- Chaque outil a un nom explicite et une description qui précise le format attendu des paramètres.
- Chaque handler revalide ses entrées indépendamment du schéma déclaratif.
- Aucun accès fichier ne sort d'un répertoire racine explicitement autorisé.
- Chaque erreur distingue clairement faute de l'appelant et panne interne.
- Aucune exception ne peut faire planter le processus serveur.
- Un script de test manuel couvre au moins un cas valide et un cas invalide par outil.
- Les secrets (clés d'API vers le système métier réel) sont lus depuis l'environnement, jamais codés en dur.
Si l'outil
get_customer_orderfinit par appeler une vraie API métier authentifiée, veillez à ce que les jetons d'accès ne soient jamais écrits dans les logs du serveur, y compris en cas d'erreur. Un message d'erreur qui inclut par mégarde l'en-têteAuthorizationcomplet dans son texte est un incident de sécurité, pas un détail de debug.
Pièges fréquents à ce stade
Plusieurs erreurs reviennent systématiquement lors des premières implémentations. La première est la sur-généralisation évoquée plus haut : vouloir un outil unique et paramétrable plutôt que plusieurs outils spécialisés. La seconde est l'oubli de limites raisonnables sur les paramètres numériques ou les tailles de résultats — un limit sans borne supérieure peut conduire un modèle à demander des milliers de résultats et saturer la réponse. La troisième est la confusion entre erreur de protocole (JSON-RPC malformé, géré par le SDK) et erreur métier (paramètre invalide, géré par le handler) : les deux ne doivent pas emprunter le même chemin de traitement.
Enfin, une erreur plus subtile concerne la description des outils elle-même : la rédiger comme une documentation technique destinée à un développeur, plutôt que comme une instruction destinée à guider un modèle dans son choix. Une description efficace anticipe les questions que l'outil peut résoudre et les formats d'entrée attendus, plutôt que de simplement nommer la fonction sous-jacente.
Conclusion
Ce chapitre a posé les bases concrètes d'un serveur MCP fonctionnel : trois outils représentatifs, une validation systématique côté serveur, des messages d'erreur pensés pour guider l'appelant plutôt que pour l'informer vaguement d'un échec, et une méthode de test reproductible avant toute intégration avec un client réel. Le chapitre suivant s'appuiera sur cette base pour aborder les ressources et les invites (prompts) exposées par un serveur MCP, ainsi que les questions de permissions et de consentement utilisateur lorsque les outils touchent à des actions sensibles.
L'essentiel à retenir
Ce chapitre guide la construction d'un premier serveur MCP fonctionnel exposant trois outils représentatifs : lecture de fichier, recherche documentaire et appel à une API métier factice. Il couvre la structure de projet, la déclaration des outils avec leurs schémas d'entrée, la validation systématique côté serveur et la conception de messages d'erreur exploitables par un modèle. Un script de test manuel permet de vérifier le comportement du serveur avant toute intégration avec un client réel. Les pièges les plus fréquents des premières implémentations sont également passés en revue.
Questions fréquentes
Faut-il utiliser TypeScript ou Python pour écrire un serveur MCP ?
Peut-on exposer plus de trois outils dans un même serveur MCP ?
Comment empêcher un modèle d'appeler un outil sensible sans confirmation ?
Le schéma JSON d'un outil suffit-il à empêcher les injections ou les traversées de répertoire ?
Que faire si l'API métier appelée par un outil est lente ou instable ?
Comment tester un serveur MCP sans avoir accès à un assistant complet ?
Faut-il documenter les outils en anglais ou en français ?
Quelle est l'erreur la plus fréquente chez les équipes qui construisent leur premier serveur MCP ?
Progression sauvegardée dans votre navigateur.
Quiz de validation
Quiz Player
Quiz de validation
Plusieurs réponses possibles — validez ensuite.
Vrai ou faux.
Quiz indisponible (données invalides).