Serveurs MCP pour agents IA : de la démo à la production
Monter un serveur MCP prend un après-midi. En faire tourner un que de vrais agents IA sollicitent en production, c'est un autre problème. Voici ce qu'on a bien fait, ce qui nous a piégés, et comment notre serveur à 91 outils est vraiment bâti.
Jun 28, 2026
Vous pouvez monter un serveur MCP en un après-midi. Ça fonctionne, votre agent IA appelle un outil, la démo convainc. Puis vient le moment de le brancher sur vos vrais outils métier (un assistant de bureau, un agent de code, un bot Telegram, un CRM, un ERP) et de lui donner accès à de vraies données. Là, on se rend compte que ce n'est plus si simple.
Nous concevons et exploitons des serveurs MCP en production : l'un des nôtres expose 91 outils sur six domaines : CRM, gestion de projet, facturation, recherche produit. (91, c'était le chiffre quand on a écrit cet article ; on est au-delà de 200 aujourd'hui.) Nous en avons décrit l'architecture. Ce billet, c'est l'autre moitié : les décisions que le quickstart passe sous silence, celles dont on débattrait volontiers, et les pièges qui nous ont vraiment coûté du temps.
Qu'est-ce qu'un serveur MCP pour un agent IA ?
C'est une interface unique et typée entre un agent IA et vos systèmes métier. Le Model Context Protocol est le standard : au lieu de coder une intégration sur mesure pour chaque agent, vous montez un serveur qui expose des outils, et n'importe quel client compatible MCP peut les appeler. L'agent dit « cherche les contacts avec un statut en discussion », le serveur traduit en requête et renvoie des résultats structurés. L'agent ne touche jamais votre base, et votre base n'a pas besoin de savoir qu'un agent existe.
Toute l'astuce est là, dans cette indirection. Et c'est aussi de là que viennent les soucis de production, car le serveur est désormais ce qui se tient entre un agent autonome et vos vraies données.
On n'a pas pris de SDK MCP
Le protocole est petit : initialize, tools/list, tools/call, ping, deux ou trois notifications, le tout en JSON-RPC 2.0. On l'a implémenté à la main, une centaine de lignes de Go, calé sur la version 2025-03-26 de la spec, plutôt que de tirer un SDK.
Pour notre serveur, c'était le bon choix, et on le referait : la surface est minuscule, et on préfère posséder cent lignes que suivre une dépendance qui bouge vite pour emballer les mêmes cinq méthodes. Mais connaissez le prix avant de nous copier : quand la spec bouge, c'est nous qui la faisons bouger, à la main. Si vous avez besoin des flux OAuth, des streams reprenables et des cas tordus de la spec gérés pour vous, prenez le SDK. Si votre serveur n'est qu'une porte fine et typée sur vos propres systèmes, la version maison pèse moins lourd que l'emballage autour.
Un seul serveur, deux transports, un seul registre
On fait tourner stdio et Streamable HTTP depuis le même binaire, choisis par une variable d'env TRANSPORT, et tous les deux passent par un seul registre d'outils et un seul handler : impossible que les deux transports divergent. stdio, c'est pour les clients locaux (Claude Desktop, un agent de code sur votre machine). Le transport HTTP, c'est gin qui sert POST/GET/DELETE /mcp, avec un identifiant de session dans l'en-tête Mcp-Session-Id pour les agents distants.
Le piège qui nous a coûté un après-midi : en mode stdio, stdout est le canal JSON-RPC. Tout ce qu'on y écrit d'autre (une ligne de log égarée, la bannière de démarrage de gin) corrompt le flux, et le client ne voit plus que du charabia. Tous les logs doivent partir sur stderr, et il faut museler gin (gin.SetMode(ReleaseMode)). Évident après coup, beaucoup moins à 2h du matin.
L'agent ne voit que les outils qu'il a le droit d'appeler
C'est la décision sur laquelle on a le plus d'avis. Le « multi-tenant », dans la plupart des articles, ça veut dire filtrer les résultats par tenant. On le fait, mais on filtre aussi le menu. tools/list renvoie un jeu d'outils différent selon l'appelant, sur deux axes.
D'abord, la clé d'API porte une liste blanche de modules. Ensuite, et c'est ça qui compte : chaque outil déclare un Mode (sales, service, success, client_order, client_onboard), on résout le rôle de l'appelant depuis son appartenance à l'organisation, et on cache tout outil qui ne correspond pas. Un agent commercial ne voit jamais les outils du SAV. Un client qui passe commande ne voit que la commande, rien d'autre. Le modèle ne peut pas détourner un outil qu'on ne lui a jamais montré : une garantie bien plus solide qu'espérer qu'un prompt système tienne face à un jailbreak.
Quand la résolution échoue (appelant inconnu, erreur de lookup), on retombe sur le plus petit jeu d'outils, pas le plus grand. Fail closed.
Trois types d'appelants, triés dès la porte
Le même serveur répond à trois appelants très différents, et il décide qui vous êtes avant qu'un seul outil ne tourne :
- un collaborateur authentifié : la clé porteuse renvoie vers un utilisateur et une organisation ;
- un client connu : identifié par un id de contact passé dans un blob
_identity, cadré sur son propre compte ; - un client inconnu : anonyme, basculé dans un mode d'onboarding verrouillé.
Ces deux derniers cas, c'est pourquoi un seul serveur peut se trouver à la fois derrière notre Claude Code interne et derrière le fil WhatsApp d'un client, sans deuxième base de code. (Quand un appelant a besoin d'un identifiant propre à un module, on le transmet via un token composé, mcpKey::moduleKey, plutôt que d'élargir le principal.)
Les écritures sont des dry-run sans état, pas des confirmations en deux temps
Chaque outil d'écriture prend un dry_run optionnel. Activé, l'outil fait tout sauf valider, et renvoie le changement proposé sous forme d'aperçu :
{
"dry_run": true,
"preview": {
"action": "create_invoice",
"client": "Maison Mercier",
"lines": [{ "item": "Rouge Maison 2021", "qty": 6, "unit_price": 52 }],
"total": 312
}
}
L'agent montre ça à un humain, l'humain dit oui, l'agent renvoie le même appel avec dry_run désactivé. On a volontairement évité la confirmation en deux temps avec un token gardé côté serveur : ça voudrait dire de l'état de session, des expirations, du nettoyage, et un serveur qui doit se souvenir des choses entre deux appels. Le renvoi sans état est plus moche sur le papier et bien plus simple en production. C'est cette étape de confirmation qui empêche une écriture hallucinée d'atterrir pour de bon.
Laissez Go faire les calculs, le modèle faire le jugement
La ligne la plus utile qu'on ait tracée, c'est entre le travail déterministe et le raisonnement. Notre module Supervisor expose des règles d'anomalie étiquetées par kind. Les règles calculation sont évaluées côté serveur en Go : seuils, comptages, écarts, tout ce que le code fait parfaitement et qu'un LLM fait cher et parfois faux. Les règles llm sont confiées à l'agent, qui les déroule dans une boucle ReAct. Ne demandez pas au modèle d'additionner des totaux de facture ; ne demandez pas à Go de juger si un message client a l'air agacé. Envoyez chacun du côté qui sait vraiment faire, et rendez la frontière explicite dans les données.
Planter vite, au démarrage
Quand un module s'enregistre, il vérifie la santé de son backend avec un timeout de 10 secondes. Si une dépendance dont il a besoin est injoignable, le serveur refuse de démarrer plutôt que de monter à moitié et de planter au premier appel d'outil ; un endpoint /health couvre le reste. On préfère un déploiement qui échoue bruyamment à un agent qui découvre en pleine conversation que la facturation n'a jamais été joignable.
Les erreurs portent une catégorie ; Sentry regroupe par outil
Chaque erreur est un apperror avec une catégorie (validation, domain, infrastructure, security, system, network) et un code, traduit en un message sur lequel le modèle peut agir plutôt qu'une stack trace brute. Les erreurs de production partent dans Sentry, étiquetées avec le nom de l'outil et la catégorie : « quel outil a déconné cette semaine » devient un seul regroupement, pas un grep de logs. Le point faible, en toute honnêteté : on ne mesure pas encore la latence par outil. Si on refaisait l'observabilité aujourd'hui, ce serait la première chose à ajouter.
Si vous en passez un en production
La todo list courte, à peu près dans l'ordre de ce que sauter chaque point nous a coûté :
- Possédez votre JSON-RPC et figez la version du protocole, ou prenez un SDK en connaissance de cause : décidez, ne dérivez pas.
- Servez tous les transports depuis un seul registre pour que le comportement ne puisse pas diverger.
- En stdio, gardez stdout propre : logs sur stderr, bannières de framework coupées.
- Filtrez la liste d'outils par appelant, par portée de clé et par rôle, et fail closed quand vous n'arrivez pas à le résoudre.
- Encadrez les écritures par un dry-run qui renvoie un aperçu lisible, et gardez-le sans état.
- Séparez les vérifs déterministes (le code) du jugement (le modèle), et dites clairement qui fait quoi.
- Vérifiez la santé des dépendances au démarrage et refusez de démarrer si l'une est tombée.
- Donnez une catégorie aux erreurs et étiquetez-les par outil pour voir ce qui casse vraiment.
L'essentiel
Une démo prouve le protocole. La production, c'est un tas de petites décisions tranchées : faire ou acheter, ce que l'agent voit, qui a le droit d'écrire, ce qui a le droit de faire échouer le démarrage. Aucune n'est dans le quickstart, et toutes font la différence entre une démo qui convainc et un serveur à qui vous confieriez de vraies données.
Nous concevons et exploitons des serveurs MCP en tant que service : les outils, l'authentification, le cadrage, le déploiement. Dites-nous votre stack et nous la cadrons.