Chez Kasual, quand je demande à Claude « qu'est-ce qui a été mergé sur le site cette semaine ? » ou « crée une user story dans Taiga pour ce bug », il ne devine pas : il appelle notre propre serveur MCP. Ce serveur fait le pont entre l'IA et trois outils qu'on utilise tous les jours : notre GitLab, notre Taiga, et l'API de ce site. L'article que vous lisez a d'ailleurs été déposé en brouillon sur le site par ce même serveur.
Si vous cherchez « créer un serveur MCP », vous trouverez des dizaines de tutoriels « en 10 minutes ». Ils ne mentent pas : avec FastMCP, un serveur qui répond s'écrit vraiment en dix lignes. Mais ce n'est pas là que se passe le travail. Le travail, c'est d'écrire des outils qu'un modèle de langage utilise correctement, et d'exposer le tout sans ouvrir votre GitLab au premier venu.
Ce tutoriel suit donc le chemin complet, du hello world à la prod, en s'appuyant sur notre propre serveur. Il faut connaître Python et les API REST ; aucune connaissance préalable de MCP n'est nécessaire.
MCP en deux minutes
MCP (Model Context Protocol) est un standard ouvert qui définit comment une application d'IA découvre et appelle des outils externes. Trois rôles :
- L'hôte : l'application que vous utilisez (Claude, Claude Code, VS Code, Cursor…).
- Le client : la partie de l'hôte qui maintient la connexion à un serveur.
- Le serveur : votre code, qui expose des capacités.
Un serveur peut exposer trois types de choses : des outils (des fonctions que le modèle peut appeler), des ressources (des données en lecture seule à joindre au contexte) et des prompts (des modèles d'instructions réutilisables).[^3] En pratique, ce sont les outils qui comptent : tous les clients les gèrent, et c'est ce que notre serveur expose exclusivement.
Point important : le modèle ne fait que proposer un appel d'outil. C'est l'hôte qui décide de l'exécuter, souvent après validation de l'utilisateur.
Sous le capot, tout passe en JSON-RPC (tools/list, tools/call…) sur l'un des deux transports standard :[^1]
- stdio : L'hôte lance votre serveur comme sous-processus et dialogue via stdin/stdout. Quand l'utiliser : outil local, un seul utilisateur.
- Streamable HTTP : Votre serveur tourne seul et expose un unique endpoint HTTP (ex.
/mcp). Quand l'utiliser : serveur partagé, distant, en équipe.
Vous croiserez encore des tutoriels basés sur HTTP+SSE. Ce transport a été remplacé par Streamable HTTP dans la version 2025-03-26 de la spécification, et FastMCP ne le conserve que pour la compatibilité avec d'anciens clients.[^1][^2] Pour un nouveau projet, partez directement sur Streamable HTTP.
Étape 1 : un serveur minimal
On installe FastMCP, le framework Python de référence, construit au-dessus du SDK officiel :
python3 -m venv .venv
source .venv/bin/activate
pip install fastmcpPuis un fichier server.py :
from fastmcp import FastMCP
mcp = FastMCP("Mon-Serveur")
@mcp.tool
def bonjour(nom: str) -> str:
"""Salue une personne par son nom."""
return f"Bonjour, {nom} !"
if __name__ == "__main__":
mcp.run(transport="http", host="127.0.0.1", port=8000)python server.py, et votre serveur écoute sur http://127.0.0.1:8000/mcp.[^2] FastMCP a lu la signature de la fonction (nom: str) pour générer le schéma JSON des paramètres, et la docstring pour la description de l'outil.
Avant de brancher un vrai client, testez avec l'inspecteur officiel :
npx @modelcontextprotocol/inspectorIl ouvre une interface web : connectez-vous en « Streamable HTTP » à votre URL, listez les outils, appelez-les et regardez les messages bruts. Le bon ordre de débogage : les logs du serveur d'abord, l'inspecteur ensuite, et le client IA en dernier.[^3] Si ça ne marche pas dans l'inspecteur, ça ne marchera pas dans Claude, et l'inspecteur vous dira pourquoi bien plus clairement.
Voilà le « 10 minutes » des autres tutos. La suite, c'est ce qui sépare une démo d'un outil qu'on utilise vraiment.
Étape 2 : écrire un outil qu'un LLM utilise bien
Notre serveur expose plus de soixante-dix outils. En les écrivant, on a convergé vers quelques règles. Voici une version simplifiée de create_site_article, l'outil qui a créé le brouillon de cet article :
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": False, "openWorldHint": True})
def create_site_article(slug: str, title: str, summary: str, category: str,
body: str, status: str = "draft") -> str:
"""Create a blog article on the Kasual website (created as a draft by default).
Limits: slug lowercase kebab-case 2–100 chars and immutable once created;
title 2–160 chars; summary 20–500 chars; body 20–50 000 chars.
Existing categories: IoT, Tech, Design, Produit, IA (see list_site_article_categories).
Args:
slug: URL slug, lowercase kebab-case (e.g. 'capteurs-iot-velo').
status: 'draft' (default) or 'published' (needs the 'publish' permission).
"""
if status not in ("draft", "published"):
return f"Validation Error: status must be 'draft' or 'published' (got '{status}')."
data, err = _request("POST", "/articles", body={
"slug": slug, "title": title, "summary": summary,
"category": category, "body": body, "status": status,
})
if err:
return err
return "Article created.\n" + _format_item(data.get("item") or {})Ce qui compte ici :
La docstring est la documentation du modèle. C'est tout ce que le LLM sait de votre outil. On y met les limites (longueurs, formats), les valeurs possibles, et surtout les liens entre outils (« voir list_site_article_categories »). Un modèle qui connaît les contraintes d'avance fait beaucoup moins d'appels ratés. On les écrit en anglais, comme le reste du code : les modèles s'en accommodent très bien, et le contenu produit reste en français.
Un outil ne lève jamais d'exception. Toute erreur devient une chaîne explicite : Validation Error: ..., Error 409: ..., Configuration Error: KASUAL_SITE_TOKEN is not set.... Une exception Python remonte au client sous forme d'erreur générique ; un message clair permet au modèle de corriger son appel tout seul, ou de vous expliquer précisément ce qui manque.
Un helper unique pour les appels HTTP. Tous nos outils « site » passent par une fonction _request qui renvoie un tuple (data, erreur). Le timeout, les en-têtes d'authentification, la conversion des codes HTTP en messages lisibles : tout est centralisé une fois, et chaque outil tient en quelques lignes.
Des annotations honnêtes. readOnlyHint, destructiveHint et openWorldHint indiquent au client si l'outil lit, modifie ou détruit. L'hôte peut s'en servir pour demander confirmation avant une action risquée. Chez nous, publish_site_article est marqué destructif, list_site_articles en lecture seule.
Des valeurs par défaut prudentes. L'article est créé en brouillon par défaut, et la publication est un outil séparé. Le jour où un modèle se trompe, il se trompe sur un brouillon.
Des sorties texte compactes. Plutôt que de renvoyer le JSON brut de l'API (souvent verbeux), on formate une version lisible : identifiant, titre, statut, version, URL. Le modèle consomme moins de contexte et comprend mieux.
Étape 3 : organiser le code par domaine
Avec trois intégrations, un seul fichier devient vite illisible. Notre structure :
server.py # point d'entrée : crée le serveur, enregistre les outils
config.py # lecture des variables d'environnement
gitlab_tools.py # outils GitLab
taiga_tools.py # outils Taiga
kasual_site_tools.py # outils du site
middleware.py # authentification
tests/ # tests hors ligneChaque module expose une fonction register_*_tools(mcp) qui déclare ses outils sur l'instance partagée :
# kasual_site_tools.py
def register_kasual_site_tools(mcp: FastMCP) -> None:
@mcp.tool(annotations={"readOnlyHint": True})
def get_site_api_identity() -> str:
"""Show which Kasual website account the configured API token belongs to..."""
...
# server.py
mcp = FastMCP("Org-GitLab-Taiga-Bridge")
register_gitlab_tools(mcp)
register_taiga_tools(mcp)
register_kasual_site_tools(mcp)Ajouter une intégration revient à créer un module et une ligne dans server.py. Toute la configuration (URL, jetons) passe par des variables d'environnement, avec un .env.example versionné et le vrai .env dans le .gitignore.
Étape 4 : sécuriser avant d'exposer
C'est l'étape que les tutos rapides sautent, et la plus importante. Un serveur MCP, c'est un accès programmatique à vos outils internes. Le nôtre peut lire tout notre code GitLab et publier sur ce site. Sans protection, n'importe qui atteignant le port aurait les mêmes droits.
La spécification est claire sur le transport HTTP : le serveur doit valider l'en-tête Origin de chaque requête (contre les attaques par DNS rebinding), devrait n'écouter que sur 127.0.0.1 en local, et devrait authentifier toutes les connexions.[^1] Voici comment on traduit ça.
Refuser de démarrer sans clé. Plutôt qu'un serveur ouvert par oubli, on échoue à l'import :
if not MCP_API_KEY and not ALLOW_NO_AUTH:
raise SystemExit("Refusing to start: MCP_API_KEY is not set.")ALLOW_NO_AUTH=1 existe pour les essais locaux, mais il faut le demander explicitement.
Comparer la clé en temps constant. Un middleware ASGI vérifie X-API-Key ou Authorization: Bearer avant que la requête n'atteigne FastMCP, avec hmac.compare_digest plutôt que == (qui laisse fuiter de l'information par le temps de réponse) :
client_key = api_key_header or bearer_token
if not client_key or not hmac.compare_digest(client_key.encode(), self.api_key.encode()):
# réponse 401Générez la clé avec openssl rand -hex 32.
Valider l'origine et restreindre CORS. Ne copiez pas un allow_origins=["*"] trouvé dans un exemple : listez les origines que vous attendez et rejetez les autres, comme l'exige la spec.
Protéger les secrets en aval. Nos outils refusent d'envoyer le jeton du site sur une URL en http://, sauf vers localhost en développement. Le compte utilisé par le serveur a un rôle dédié : « Rédacteur » s'il ne doit créer que des brouillons, « Éditeur » s'il doit publier. Le modèle n'aura jamais plus de droits que ce compte.
Penser à l'injection de prompt. Le modèle lit des contenus que vous ne contrôlez pas (tickets, fichiers, pages web). Un texte piégé peut le pousser à appeler vos outils avec des arguments malveillants. Notre outil d'upload d'image, par exemple, est désactivé par défaut pour les fichiers disque ; quand on l'active, il ne lit que dans un dossier précis (liens symboliques résolus), vérifie la taille et les octets magiques PNG/JPEG/WebP. Sinon, une simple phrase dans un ticket pourrait faire publier /etc/passwd sur le site.
Ne pas loguer les payloads. Les logs de débogage qui affichent le corps complet des requêtes sont pratiques pendant le développement, mais en production ils recopient jetons, contenus clients et données personnelles dans vos fichiers de logs. Logguez le nom de l'outil et la durée, pas les arguments.
Durcir le conteneur. Notre docker-compose.yml :
services:
mcp-bridge:
build: .
restart: always
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
mem_limit: 512m
pids_limit: 256
ports:
- "127.0.0.1:8000:8000"
env_file: .envSystème de fichiers en lecture seule, aucune capacité Linux, utilisateur non root dans l'image, et surtout un port publié uniquement sur 127.0.0.1. Le monde extérieur passe par Nginx, qui gère le HTTPS et la limitation de débit.
Une clé API partagée convient pour un serveur interne utilisé par une petite équipe. Si vous voulez que chaque utilisateur se connecte avec son propre compte et ses propres droits, la spécification MCP prévoit OAuth 2.1, et FastMCP intègre des fournisseurs d'authentification pour ça.[^3] C'est plus de travail, mais c'est la bonne cible pour un serveur ouvert à des tiers.
Étape 5 : tester, déployer, connecter
Tester hors ligne. Nos tests pytest simulent les couches HTTP de GitLab, Taiga et du site : aucune clé ni accès réseau n'est nécessaire, ils tournent donc en CI. Pour appeler un outil comme le ferait un vrai client, FastMCP fournit un client en mémoire :
# pip install pytest pytest-asyncio
import pytest
from fastmcp import Client
from server import mcp
@pytest.mark.asyncio
async def test_bonjour():
async with Client(mcp) as client:
result = await client.call_tool("bonjour", {"nom": "Kasual"})
assert "Bonjour, Kasual" in result.content[0].textTestez en priorité les chemins d'erreur : jeton manquant, 404, 409, entrée invalide. Ce sont eux qui déterminent si le modèle saura se rattraper.
Servir derrière Uvicorn. Pour ajouter nos middlewares, on récupère l'application ASGI de FastMCP plutôt que d'appeler mcp.run() :[^2]
app = mcp.http_app(path="/")
app.add_middleware(APIKeyMiddleware)
if __name__ == "__main__":
uvicorn.run(app, host=HOST, port=PORT)Puis docker compose up -d --build, et un bloc location Nginx qui transmet https://mcp.votre-domaine.fr/mcp vers 127.0.0.1:8000. Désactivez le buffering de Nginx sur ce bloc (proxy_buffering off;) : les réponses peuvent arriver en flux.
Connecter Claude. Dans Claude Code, une ligne suffit :
claude mcp add --transport http kasual https://mcp.votre-domaine.fr/mcp \
--header "X-API-Key: $MCP_API_KEY"Premier réflexe une fois connecté : appeler un outil d'identité. Chez nous, get_site_api_identity répond avec le compte, le rôle et la date d'expiration du jeton. Si ça répond, toute la chaîne fonctionne : réseau, authentification, configuration. 🚀
Dernier conseil d'exploitation : prévoyez la rotation des secrets dès le départ. Créez le nouveau jeton, mettez à jour le .env, redémarrez, puis révoquez l'ancien. Si le processus est documenté, vous le ferez vraiment.
À retenir
- Le serveur est la partie facile. Avec FastMCP,
@mcp.tooletmcp.run(transport="http")suffisent pour démarrer. Utilisez Streamable HTTP, pas SSE. - La qualité se joue dans les outils. Docstrings précises, erreurs renvoyées en texte clair, valeurs par défaut prudentes, annotations honnêtes : c'est ce qui fait qu'un modèle utilise bien vos outils.
- Testez avec MCP Inspector avant de tester avec une IA. Les erreurs y sont lisibles, alors que dans un chat elles se perdent dans la conversation.
- Sécurisez comme une API d'administration, parce que c'en est une : authentification obligatoire, validation de l'
Origin, compte de service aux droits minimaux, conteneur durci, port local derrière un reverse proxy. - Pensez injection de prompt. Chaque outil qui écrit ou lit le disque doit supposer que ses arguments peuvent venir d'un texte malveillant.
Si vous voulez brancher vos propres outils métier sur Claude, sans y passer vos week-ends sur la sécurité, parlons de votre API.
[^1]: Model Context Protocol. "Transports" (spécification 2025-06-18). modelcontextprotocol.io. https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
[^2]: FastMCP. "Running Your Server." gofastmcp.com. https://gofastmcp.com/deployment/running-server
[^3]: Pamela Fox. "Building MCP servers with Python (PyCon 2026 tutorial)." pamelafox.github.io. https://pamelafox.github.io/pycon2026-mcp-tutorial/



