Aller au contenu
NET&PRO...
Tutoriels

Brancher un modèle sur son SI : écrire un serveur MCP pas à pas

Le Model Context Protocol en pratique : l'architecture, un serveur MCP minimal qui expose une base métier en lecture, et les pièges avant la production.

Photographie studio sur fond bleu cobalt, un enchevêtrement de câbles dépareillés à gauche et un panneau de brassage aux prises uniformes à droite

Écrire un serveur MCP prend une trentaine de lignes. Décider ce qu'il a le droit de lire prend beaucoup plus longtemps, et c'est la partie que les tutoriels sautent. Ce pas-à-pas fait les deux : l'architecture réelle du protocole, un serveur qui expose une base métier en lecture seule, puis ce qui casse quand on le met en production.

Le problème : un connecteur par outil, par modèle, par client

Vous avez un modèle de langue et vous voulez qu'il lise votre CRM. Vous écrivez un connecteur. Puis vous voulez qu'il lise aussi le SIRH, vous en écrivez un deuxième. Puis vous changez d'application cliente, et vos deux connecteurs, écrits pour l'ancienne, sont à refaire.

C'est une multiplication, pas une addition : M applications clientes fois N sources de données. Chaque case de ce tableau est du code à écrire, à tester et à maintenir, alors que la logique dessous est toujours la même : ouvrir une connexion, exécuter une requête bornée, renvoyer un résultat lisible par le modèle.

Le Model Context Protocol s'attaque à ça : décrire une fois comment une application qui pilote un modèle demande des données et déclenche des actions, de sorte que le même serveur serve n'importe quel client conforme. La comparaison qui revient partout est celle de l'USB-C. Elle est juste sur le principe, et trompeuse sur un point : USB-C transporte du courant et des octets, MCP transporte des permissions.

Sur quelle version on s'appuie

Point de méthode, parce que beaucoup d'articles décrivent MCP au présent sans dire lequel. La spécification est publique et versionnée, et son numéro de version est une date au format YYYY-MM-DD : celle du dernier changement incompatible. Une révision qui reçoit des ajouts rétrocompatibles ne change pas de numéro.

Cet article s'appuie sur la révision 2025-11-25, courante à la date d'écriture. Elle succède à 2025-06-18, laquelle avait supprimé le regroupement de messages JSON-RPC et introduit la sortie structurée des outils. La 2025-11-25 apporte la découverte OpenID Connect, le consentement de portée incrémental via l'en-tête WWW-Authenticate, des icônes en métadonnées, et un support déclaré expérimental des tâches longues avec récupération différée du résultat.

Vérifiez la révision courante avant de démarrer : le projet bouge vite, et une signature recopiée d'un billet de l'an dernier ne compile pas forcément.

L'architecture : hôte, client, serveur

Trois rôles, et le vocabulaire est piégeux parce que « client » ne désigne pas ce qu'on croit.

L'hôte est l'application que l'utilisateur voit : un assistant de bureau, un IDE, votre propre application. C'est lui qui parle au modèle, qui agrège le contexte, et qui détient les décisions d'autorisation.

Le client est un composant interne à l'hôte. Un hôte en instancie autant qu'il a de connexions, avec une relation stricte de un pour un : un client, un serveur, une session. Cet isolement est délibéré : la spécification pose comme principe de conception qu'un serveur ne doit pouvoir ni lire l'intégralité de la conversation, ni voir ce que font les autres serveurs.

Le serveur est ce que vous écrivez. Il expose trois primitives :

  • des ressources, à peu près l'équivalent d'un GET : de la donnée qu'on charge dans le contexte ;
  • des outils, à peu près l'équivalent d'un POST : du code qu'on exécute, avec effet de bord possible ;
  • des prompts, des gabarits d'interaction réutilisables.

La distinction ressource/outil n'est pas cosmétique. Elle dit qui décide : une ressource est en général sélectionnée par l'application ou l'utilisateur, un outil est appelé par le modèle. C'est le premier endroit où votre découpage engage la sécurité.

Qui appelle qui. Dans le sens courant, le client émet une requête et le serveur répond. Mais le canal est bidirectionnel. Le sampling permet au serveur de demander une complétion au modèle sans embarquer ni clé d'API ni fournisseur ; l'elicitation lui permet de réclamer une information manquante à l'utilisateur en cours d'exécution. Dans les deux cas c'est l'hôte qui arbitre : le serveur ne touche jamais directement ni au modèle ni à l'utilisateur.

La session s'ouvre par une négociation de capacités : chacun déclare ce qu'il sait faire, et rien de non déclaré n'est utilisable ensuite.

Au crépuscule sur une piste, deux mains se passent un témoin de métal lisse

Les deux transports

Le protocole encode ses messages en JSON-RPC, en UTF-8, et définit deux transports standard.

stdio. Le client lance le serveur comme sous-processus et parle avec lui par l'entrée et la sortie standard, un message JSON-RPC par ligne. Deux règles expliquent la moitié des serveurs qui « ne démarrent pas » : le serveur ne doit rien écrire sur stdout qui ne soit pas un message MCP valide, et les messages ne doivent pas contenir de saut de ligne interne. Votre print() de débogage casse le protocole. Les journaux vont sur stderr, où la spécification autorise explicitement tous les niveaux, pas seulement les erreurs. En contrepartie, la surface d'attaque est minimale : pas de port ouvert, pas d'authentification à gérer, un processus par utilisateur.

Streamable HTTP. Le serveur est un processus indépendant qui sert plusieurs clients sur un point d'entrée HTTP unique, en POST et en GET, avec du Server-Sent Events quand il doit pousser des messages. Il remplace l'ancien transport HTTP+SSE de la révision 2024-11-05. C'est le transport de la production multi-utilisateurs, et celui qui vous fait hériter des problèmes d'un service web exposé.

La spécification y attache un avertissement en trois points, dont un impératif : le serveur doit valider l'en-tête Origin de toute connexion entrante et répondre 403 Forbidden s'il est présent et invalide, sous peine d'attaque par redéploiement DNS. Il devrait en local n'écouter que sur 127.0.0.1, et implémenter une authentification digne de ce nom. Sans quoi n'importe quel site ouvert dans le navigateur de l'utilisateur peut dialoguer avec le serveur MCP qui tourne sur son poste.

Commencez en stdio. Vous passerez en HTTP le jour où plusieurs personnes devront partager le serveur, pas avant.

Le pas-à-pas : exposer une base métier en lecture

L'exemple utile n'est pas un serveur météo, c'est celui qui donne au modèle un accès borné à une base existante. On expose un CRM en lecture seule.

Le SDK Python est à cette date en version 1.28.x, et son ergonomie repose sur FastMCP, qui construit le schéma JSON des outils à partir des annotations de type et de la docstring. Vous n'écrivez pas de JSON Schema à la main.

uv init mcp-crm && cd mcp-crm
uv add "mcp[cli]"

Le serveur, serveur.py :

import sqlite3
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("CRM lecture seule")

# mode=ro : SQLite refuse toute écriture, y compris si la requête en contient une
DSN = "file:crm.db?mode=ro"


def lire(sql: str, params: tuple = ()) -> list[dict]:
    with sqlite3.connect(DSN, uri=True) as cx:
        cx.row_factory = sqlite3.Row
        return [dict(ligne) for ligne in cx.execute(sql, params).fetchall()]


@mcp.tool()
def chercher_client(nom: str, limite: int = 10) -> list[dict]:
    """Cherche des clients dont le nom contient le texte fourni.

    Renvoie l'identifiant, le nom, la ville et le statut commercial.
    N'expose ni le chiffre d'affaires ni les coordonnées personnelles.
    """
    limite = max(1, min(limite, 50))
    return lire(
        "SELECT id, nom, ville, statut FROM clients "
        "WHERE nom LIKE ? ORDER BY nom LIMIT ?",
        (f"%{nom}%", limite),
    )


@mcp.resource("crm://client/{client_id}")
def fiche_client(client_id: str) -> str:
    """Fiche d'un client, identifié par son identifiant interne."""
    lignes = lire(
        "SELECT nom, ville, statut, cree_le FROM clients WHERE id = ?",
        (client_id,),
    )
    if not lignes:
        return f"Aucun client {client_id}."
    c = lignes[0]
    return f"{c['nom']} ({c['ville']}), statut {c['statut']}, client depuis {c['cree_le']}."


if __name__ == "__main__":
    mcp.run(transport="stdio")

Trois choses méritent qu'on s'y arrête, parce qu'elles ne sont pas décoratives.

La docstring est de l'interface, pas du commentaire. C'est ce texte que le modèle lit pour décider d'appeler l'outil et avec quels arguments. Dire ce que l'outil ne renvoie pas est aussi utile que dire ce qu'il renvoie : ça lui évite d'insister.

Le SQL est paramétré et la requête est figée. La tentation de l'outil executer_sql(requete: str) est immense, et c'est précisément le piège : vous donneriez à un système probabiliste un accès arbitraire à votre base, sur la foi d'un texte qui peut venir d'un courriel. Exposez des questions, pas un interpréteur.

Les colonnes sont énumérées. Pas de SELECT *. Ce qui n'est pas dans la liste ne sortira jamais de la base, même si quelqu'un ajoute une colonne salaire la semaine prochaine.

Vérifier avant de brancher

Ne branchez pas ce serveur sur votre assistant pour voir s'il marche. Testez-le d'abord seul, avec l'inspecteur officiel :

npx -y @modelcontextprotocol/inspector

L'inspecteur ouvre une interface où vous voyez les outils déclarés, leur schéma généré, et où vous les appelez à la main. Deux minutes ici vous évitent une demi-heure à chercher pourquoi le modèle « ne voit pas » votre outil, alors que le serveur plante au démarrage sur un import manquant. Le mode d'échec le plus fréquent est silencieux : le serveur démarre, le client se connecte, aucun outil n'apparaît. Regardez stderr, et vérifiez que rien d'autre que du JSON-RPC ne part sur stdout.

💡 La question à poser avant d'écrire la première ligne

« Si ce serveur était appelé cent fois par jour par quelqu'un de malveillant qui rédige les arguments, qu'est-ce qu'il pourrait en tirer ? » Répondez avant de coder, pas après. Un serveur MCP est une porte ouverte sur vos données, et son périmètre est décidé au moment où vous choisissez les colonnes du SELECT. Si l'exercice touche à des données réglementées, c'est le genre de cadrage que nous traitons dans nos accompagnements IA.

Illustration à plat, olive et sable : une main présente une grande enveloppe à la fente étroite d'une porte

Droits et périmètre : ce que le protocole ne fait pas à votre place

MCP standardise le transport et le consentement. Il ne décide pas qui a le droit de lire quoi. Cette partie reste entièrement la vôtre, et la spécification consacre un document entier aux façons de la rater.

Le serveur tourne avec vos droits, pas avec ceux de l'utilisateur. En stdio, il est lancé comme sous-processus du client et hérite de ses privilèges : système de fichiers, réseau, variables d'environnement. Un serveur installé d'un clic depuis une configuration toute faite exécute une commande que l'utilisateur n'a le plus souvent pas lue. La spécification demande d'ailleurs aux clients d'afficher cette commande en entier, sans troncature, avant de l'exécuter.

Le transfert de jeton est interdit. Un serveur ne doit pas accepter un jeton qui ne lui a pas été explicitement délivré, ni le repasser tel quel à l'API en aval. C'est nommé comme un anti-patron dans la spécification : ça contourne les contrôles d'audience, ça brouille la traçabilité, et ça transforme votre serveur en relais d'exfiltration pour qui détient un jeton volé.

Les portées doivent être minimales et progressives. Demander db:* à l'ouverture de session parce que c'est plus simple, c'est se garantir un rayon d'explosion maximal le jour où le jeton fuite. Le modèle recommandé est l'inverse : portée de départ étroite, élévation ciblée au moment où une opération privilégiée est tentée.

En HTTP, l'identifiant de session n'est pas une authentification. La spécification est explicite : un serveur qui implémente une autorisation doit vérifier chaque requête entrante, et ne doit pas se servir des sessions pour authentifier. Les identifiants de session doivent être non devinables, et il est recommandé de les lier à l'utilisateur, sous une forme du type <identifiant_utilisateur>:<identifiant_session>.

Le reste des règles de sécurité d'une application qui donne un modèle en pâture à du texte extérieur ne change pas : l'injection par le contenu récupéré, l'exfiltration par un outil trop permissif et le cloisonnement des secrets sont traités dans sécuriser une application LLM, et ils s'appliquent intégralement ici. MCP ne les résout pas, il déplace juste l'endroit où on les rencontre.

Ce que ça remplace, et ce que ça ne remplace pas

Ce que ça remplace : la couche d'intégration écrite pour un client donné, et les définitions d'outils recopiées à la main dans chaque application. Le même serveur, écrit une fois, sert tous les hôtes conformes.

Ce que ça ne remplace pas, et il faut être net là-dessus :

Ce n'est pas un remplaçant de vos API. Votre serveur MCP appelle vos API existantes, il ne les périme pas. S'il n'y a pas d'API derrière, MCP ne fabrique rien.

Ce n'est pas un moteur de recherche documentaire. Exposer un corpus par une ressource ne construit ni index, ni découpage, ni classement de pertinence. Sur un fonds volumineux, la question du RAG reste posée à l'identique.

Ce n'est pas une gouvernance. Aucune certification, aucun contrôle central sur ce que publie un tiers : un serveur installé depuis un dépôt inconnu est du code exécuté sur votre poste.

Ce n'est pas une baisse de facture. Chaque outil déclaré occupe du contexte à chaque tour, et un serveur qui en expose quarante se paie à chaque appel. Les leviers sont ceux décrits dans maîtriser la facture d'API IA.

Dans le brouillard, un chemin se divise en une foule de portails identiques, une personne s'arrête au premier embranchement

Les pièges de mise en production

Cinq, par ordre de fréquence observée.

Trop d'outils. Au-delà d'une quinzaine, la sélection se dégrade : le modèle choisit mal, ou appelle deux outils voisins à la suite. Découpez en plusieurs serveurs par domaine plutôt que d'empiler.

Des sorties non bornées. Un outil qui renvoie 4 000 lignes remplit la fenêtre de contexte et fait échouer le tour suivant. Plafonnez côté serveur, comme le LIMIT ci-dessus, et non côté prompt.

Les erreurs remontées comme erreurs de protocole. La spécification recommande de renvoyer une erreur de validation d'entrée comme erreur d'exécution d'outil, pas comme erreur JSON-RPC : le modèle peut alors lire le message et se corriger tout seul. Une erreur de protocole coupe l'échange sans rien lui apprendre.

Le serveur qui bloque. Une requête lente monopolise la session. Le support des tâches de la révision 2025-11-25 vise ce cas, par sondage et récupération différée du résultat ; tant qu'il est déclaré expérimental, comptez sur des délais d'attente courts et des résultats petits.

Aucune journalisation. Vous saurez qu'un outil a été appelé, mais pas avec quels arguments ni par qui. Journalisez côté serveur dès le premier jour, comme pour les agents en production : sans trace, un comportement anormal est indébuggable.

Sur la documentation francophone

Ce qui existe en français sur MCP se compose presque entièrement de vues d'ensemble : le principe, la comparaison USB-C, la liste des primitives. Les points qui décident d'une mise en production ne sont documentés que dans la spécification anglophone : les règles normatives sur stdout, l'interdiction du transfert de jeton, la validation de l'en-tête Origin, la minimisation des portées. La page de sécurité de la spécification, vous la lirez donc en anglais : il n'y a pas d'équivalent.

Le chemin le plus court, en résumé

  1. Relevez la révision courante de la spécification et la version de votre SDK. Ne recopiez pas une signature vue ailleurs.
  2. Écrivez un serveur stdio avec deux ou trois outils, colonnes énumérées, SQL paramétré, résultats plafonnés.
  3. Testez-le seul dans l'inspecteur avant de le brancher sur quoi que ce soit.
  4. Écrivez les docstrings comme de la documentation d'API, parce que c'en est.
  5. Ne passez en HTTP que si plusieurs personnes partagent le serveur, et alors traitez-le comme un service exposé : Origin, authentification par requête, portées minimales.
  6. Journalisez les appels avec leurs arguments, dès le premier jour.

Si vous faites tourner le modèle sur votre propre matériel, le montage se complète bien avec faire tourner un LLM en local : les données comme le modèle restent alors sur votre réseau.

Partager

Le sujet de fond, dans les mémos

Un article éclaire un angle ; un mémo du Lab prend le sujet de A à Z, sommaire compris. Sur ce thème, voici où creuser.

À lire ensuite

NET&PRO

L'IA, c'est bien. L'IA qui tourne chez vous, c'est mieux.

Vous avez lu la théorie. NET&PRO s'occupe de la mise en œuvre : automatisation de process, intégration de modèles dans vos outils métier, formation de vos équipes.