Ecrire son propre serveur MCP en Python
Un serveur MCP utile, c'est 80 lignes de Python. Le vrai travail est ailleurs : dans la description de tes outils.
Temps de lecture : 20 min | Niveau : Intermediaire
Ce que tu sauras faire apres
- Monter un projet de serveur MCP Python avec
uven cinq commandes. - Ecrire un outil expose au modele avec le decorateur
@mcp.tool(). - Proteger ton serveur contre les requetes en ecriture.
- Brancher ton serveur sur Claude Code et le tester avec l'inspecteur officiel.
- Ecrire des descriptions d'outils que le modele comprend du premier coup.
⚙️ Preparer l'environnement
Prerequis documentes : Python 3.10 ou superieur, et le SDK Python MCP en version 2.0.0 ou superieure.
Installe uv si tu ne l'as pas.
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"curl -LsSf https://astral.sh/uv/install.sh | shRedemarre ton terminal ensuite, sinon la commande uv n'est pas reconnue.
Puis cree le projet :
uv init entrepot-mcp
cd entrepot-mcp
uv venv
# Windows : .venv\Scripts\activate
# macOS / Linux : source .venv/bin/activate
uv add "mcp[cli]"Note de version importante : le SDK Python est passe en v2, qui est la ligne stable actuelle et suit la version de protocole
2026-07-28. La classe de serveur s'appelle desormaisMCPServer, importee depuismcp.server. Si tu tombes sur un tutoriel qui utiliseFastMCP, c'est du v1. Le SDK recommande de garder une borne haute<2dans tes dependances (par exemplemcp>=1.28,<2) tant que tu n'as pas migre. Le code ci-dessous est en v2.
Deux decorateurs sont documentes dans le demarrage rapide du SDK : @mcp.tool() pour exposer une fonction au modele, et @mcp.resource() pour exposer une donnee en lecture. Ce tutoriel n'utilise que le premier, qui couvre 90 % des besoins.
🧪 Le serveur minimal, pour verifier que la chaine marche
Cree serveur.py :
from mcp.server import MCPServer
mcp = MCPServer("demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
if __name__ == "__main__":
mcp.run(transport="stdio")Lance-le :
uv run serveur.pyIl ne va rien afficher. C'est normal : il attend des messages JSON-RPC sur son entree standard. Fais Ctrl+C.
La classe MCPServer se sert des annotations de type Python et des docstrings pour generer automatiquement la definition de l'outil. Donc ta docstring devient la description que le modele lit, et tes types deviennent l'inputSchema. Retiens ca, c'est tout le principe.
🗄️ Le vrai serveur : interroger une base en lecture seule
Voici le serveur complet. Il expose trois outils sur une base SQLite : lister les tables, decrire une table, executer un SELECT. J'utilise SQLite parce que c'est dans la bibliotheque standard de Python, donc zero dependance en plus. La section suivante explique comment passer a PostgreSQL.
Si tu suis le tutoriel officiel en parallele, tu verras son exemple importer
httpx2comme client HTTP fourni avec le SDK. Mon code n'en a pas besoin : il parle a une base locale, pas a une API web.
Ecris ce contenu dans serveur.py :
"""Serveur MCP en lecture seule sur une base SQLite.
Expose trois outils :
- list_tables : lister les tables de la base
- describe_table : donner les colonnes et types d'une table
- run_select : executer une requete SELECT et renvoyer les lignes
Regle de conception : ce serveur ne doit JAMAIS pouvoir modifier la base.
"""
import json
import logging
import os
import re
import sqlite3
from contextlib import closing
from mcp.server import MCPServer
# IMPORTANT : en transport stdio, ecrire sur stdout casse le protocole
# JSON-RPC. Le module logging ecrit sur stderr, donc il est sur.
# N'utilise jamais print() dans ce fichier.
logger = logging.getLogger(__name__)
mcp = MCPServer("entrepot")
# Le chemin de la base vient de l'environnement, jamais du code.
DB_PATH = os.environ.get("ENTREPOT_DB_PATH", "entrepot.db")
# Garde-fous
MAX_LIGNES = 200 # on ne renvoie jamais plus que ca au modele
MOTS_INTERDITS = (
"insert", "update", "delete", "drop", "alter", "create",
"truncate", "replace", "attach", "detach", "pragma", "vacuum",
)
def _connexion() -> sqlite3.Connection:
"""Ouvre la base en lecture seule via l'URI file: et le mode=ro."""
uri = f"file:{DB_PATH}?mode=ro"
conn = sqlite3.connect(uri, uri=True)
conn.row_factory = sqlite3.Row
return conn
def _est_un_select(sql: str) -> bool:
"""Verifie que la requete est bien un SELECT unique et sans mot interdit.
Ce filtre est une ceinture. Les bretelles, c'est le compte de base
de donnees en lecture seule. Ne compte jamais sur le filtre seul.
"""
nettoye = re.sub(r"--[^\n]*", " ", sql) # retire les commentaires
nettoye = re.sub(r"/\*.*?\*/", " ", nettoye, flags=re.S)
nettoye = nettoye.strip().rstrip(";").strip()
if ";" in nettoye: # pas de requetes chainees
return False
if not nettoye.lower().startswith(("select", "with")):
return False
mots = set(re.findall(r"[a-z]+", nettoye.lower()))
return not (mots & set(MOTS_INTERDITS))
@mcp.tool()
def list_tables() -> str:
"""Liste toutes les tables de l'entrepot avec leur nombre de lignes.
Utilise cet outil EN PREMIER quand tu ne connais pas encore la structure
de la base. Il ne prend aucun argument et ne modifie rien.
"""
with closing(_connexion()) as conn:
noms = [
ligne["name"]
for ligne in conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%' "
"ORDER BY name"
)
]
resultat = []
for nom in noms:
nb = conn.execute(f'SELECT count(*) AS n FROM "{nom}"').fetchone()["n"]
resultat.append({"table": nom, "lignes": nb})
logger.info("list_tables a renvoye %d tables", len(resultat))
return json.dumps(resultat, ensure_ascii=False, indent=2)
@mcp.tool()
def describe_table(table: str) -> str:
"""Donne les colonnes, les types et les valeurs d'exemple d'une table.
Appelle cet outil AVANT d'ecrire une requete SQL sur une table, pour
connaitre les vrais noms de colonnes et eviter d'en inventer.
Args:
table: Nom exact de la table, tel que renvoye par list_tables.
"""
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table):
return f"Nom de table invalide : {table}. Appelle list_tables d'abord."
with closing(_connexion()) as conn:
colonnes = [
{"nom": c["name"], "type": c["type"], "non_nul": bool(c["notnull"])}
for c in conn.execute(f'PRAGMA table_info("{table}")')
]
if not colonnes:
return f"Table inconnue : {table}. Appelle list_tables d'abord."
exemples = [
dict(ligne)
for ligne in conn.execute(f'SELECT * FROM "{table}" LIMIT 3')
]
return json.dumps(
{"table": table, "colonnes": colonnes, "exemples": exemples},
ensure_ascii=False,
indent=2,
default=str,
)
@mcp.tool()
def run_select(sql: str) -> str:
"""Execute une requete SELECT en lecture seule et renvoie les lignes en JSON.
Seules les requetes commencant par SELECT ou WITH sont acceptees. Toute
tentative d'ecriture est refusee. Le resultat est tronque a 200 lignes :
si tu as besoin d'un total, calcule-le en SQL avec une agregation plutot
que de demander toutes les lignes.
Args:
sql: La requete SELECT a executer, en dialecte SQLite.
"""
if not _est_un_select(sql):
# Erreur metier lisible : le modele peut corriger tout seul.
return (
"Requete refusee : ce serveur est en lecture seule. "
"Seules les requetes SELECT ou WITH, sans point-virgule interne "
"et sans mot-cle d'ecriture, sont autorisees. "
"Reformule ta requete en SELECT."
)
try:
with closing(_connexion()) as conn:
curseur = conn.execute(sql)
lignes = [dict(l) for l in curseur.fetchmany(MAX_LIGNES)]
tronque = curseur.fetchone() is not None
except sqlite3.Error as erreur:
logger.warning("Echec SQL : %s", erreur)
return (
f"La requete a echoue : {erreur}. "
"Verifie les noms de tables et de colonnes avec describe_table."
)
charge = {
"lignes_renvoyees": len(lignes),
"tronque": tronque,
"donnees": lignes,
}
return json.dumps(charge, ensure_ascii=False, indent=2, default=str)
if __name__ == "__main__":
mcp.run(transport="stdio")Deux details du code qui ont leur importance
closing(...) et pas with _connexion() tout seul. En Python, with une_connexion_sqlite ouvre une transaction, il ne ferme pas la connexion. Un serveur MCP tourne longtemps : sans closing, tu accumules des connexions ouvertes a chaque appel d'outil. contextlib.closing garantit la fermeture.
fetchmany(MAX_LIGNES) et pas fetchall(). La difference est enorme sur une table de faits. fetchall() charge tout en memoire avant de tronquer. fetchmany s'arrete a 200 lignes. Le fetchone() juste apres sert uniquement a savoir s'il restait quelque chose, pour renvoyer "tronque": true au modele.
Pour passer a PostgreSQL
Trois changements seulement :
uv add psycopg[binary]et remplacesqlite3parpsycopg.- Remplace
_connexion()par une connexion qui utilise un compte technique en lecture seule (GRANT SELECTuniquement). C'est la vraie protection. - Remplace la requete de
list_tablespar une lecture deinformation_schema.tables.
Le garde-fou _est_un_select reste utile, mais ne t'appuie jamais dessus seul. Un filtre par mots-cles se contourne. Les droits de la base, non.
🔌 Declarer le serveur dans Claude Code
Une seule commande, depuis le dossier de ton projet :
claude mcp add --scope local --env ENTREPOT_DB_PATH=./entrepot.db \
--transport stdio entrepot -- uv --directory . run serveur.pyOu, en portee projet dans .mcp.json :
{
"mcpServers": {
"entrepot": {
"type": "stdio",
"command": "uv",
"args": ["--directory", "${CLAUDE_PROJECT_DIR}", "run", "serveur.py"],
"env": {
"ENTREPOT_DB_PATH": "${ENTREPOT_DB_PATH}"
}
}
}
}Deux avertissements de la doc officielle pour les serveurs lances par uv :
- Il faut parfois indiquer le chemin complet de l'executable
uvdanscommand. Trouve-le avecwhere uvsur Windows,which uvsur macOS et Linux. - Passe toujours un chemin absolu vers ton serveur. Sur Windows, utilise des doubles antislash
\\ou des slash/dans le JSON. Le tutoriel officiel ecrit d'ailleurs le chemin en dur, pas une variable.
Exemple Windows complet, avec des chemins absolus :
{
"mcpServers": {
"entrepot": {
"type": "stdio",
"command": "C:\\Users\\moi\\.local\\bin\\uv.exe",
"args": ["--directory", "C:\\projets\\entrepot-mcp", "run", "serveur.py"],
"env": {
"ENTREPOT_DB_PATH": "C:\\projets\\entrepot-mcp\\entrepot.db"
}
}
}
}Verifie :
claude mcp list
claude mcp get entrepot🔍 Tester avec l'inspecteur officiel
Avant de brancher ton serveur sur Claude Code, teste-le isolement. L'outil officiel s'appelle MCP Inspector.
npx @modelcontextprotocol/inspectorIl propose trois interfaces : une interface web (par defaut), un mode ligne de commande avec --cli pour l'automatisation et la CI, et un mode terminal interactif avec --tui.
Ce que je verifie systematiquement avec l'inspecteur :
- Les trois outils apparaissent bien dans
tools/list. - La description de chaque outil est celle que j'ai ecrite.
run_selectavecDELETE FROM commandesrenvoie bien un refus et ne casse pas le serveur.run_selectsur une grosse table renvoie bien"tronque": true.
✍️ Bien decrire ses outils
C'est la partie qui fait la difference entre un serveur inutilisable et un serveur que le modele utilise correctement. Rappelle-toi : le modele ne voit que le name, la description et l'inputSchema. Il ne lit pas ton code.
Regles de nommage issues de la spec
- Entre 1 et 128 caracteres.
- Sensible a la casse.
- Uniquement lettres ASCII, chiffres,
_,-et.. Pas d'espace, pas de virgule. - Unique a l'interieur du serveur.
La spec conseille aussi un nom explicite : calculator_arithmetic plutot que calculate.
AVANT / APRES sur une description
AVANT
@mcp.tool()
def query(sql: str) -> str:
"""Runs a query."""
...Le modele ne sait pas : sur quelle base ? En ecriture ? Quel dialecte SQL ? Combien de lignes reviennent ? Que faire en cas d'erreur ? Il va deviner, et il va deviner mal.
APRES
@mcp.tool()
def run_select(sql: str) -> str:
"""Execute une requete SELECT en lecture seule et renvoie les lignes en JSON.
Seules les requetes commencant par SELECT ou WITH sont acceptees. Toute
tentative d'ecriture est refusee. Le resultat est tronque a 200 lignes :
si tu as besoin d'un total, calcule-le en SQL avec une agregation plutot
que de demander toutes les lignes.
Args:
sql: La requete SELECT a executer, en dialecte SQLite.
"""Pourquoi c'est mieux : la description repond aux quatre questions que le modele se pose. Quand l'utiliser, ce qu'elle refuse, ce qu'elle renvoie, quoi faire a la place quand la limite gene.
La checklist d'une bonne description
| Question | A ecrire dans la description |
|---|---|
| Quand l'utiliser ? | "Appelle cet outil AVANT d'ecrire une requete SQL sur une table" |
| Quand ne pas l'utiliser ? | "N'utilise pas cet outil pour compter : fais une agregation SQL" |
| Que renvoie-t-il exactement ? | "Un JSON avec les cles lignes_renvoyees, tronque et donnees" |
| Quelles sont ses limites ? | "Tronque a 200 lignes" |
| Quel outil appeler avant ? | "Utilise list_tables d'abord" |
Erreurs : fais en sorte que le modele se corrige
La spec distingue les erreurs de protocole des erreurs d'execution d'outil. Ces dernieres contiennent un retour exploitable que le modele peut utiliser pour se corriger et retenter avec des parametres ajustes.
Concretement : quand ta requete echoue, ne renvoie pas juste Error. Renvoie ce qui s'est passe et quoi faire.
Mauvais : "Error: no such column"
Bon : "La requete a echoue : no such column: montant_ht.
Verifie les noms de colonnes avec describe_table."Les annotations, en option
Tu peux declarer les indices de comportement vus au fichier 02 (readOnlyHint, destructiveHint, idempotentHint, openWorldHint). Ils aident les clients a presenter tes outils correctement.
Mais rappelle-toi l'avertissement du schema officiel : ce sont des indices, sans garantie, et un client ne doit jamais fonder une decision de securite dessus. Tes annotations ne remplacent pas le compte en lecture seule.
📋 Template : un nouvel outil MCP
@mcp.tool()
def [NOM_DE_L_OUTIL_EN_SNAKE_CASE]([PARAM_1]: [TYPE], [PARAM_2]: [TYPE] = [DEFAUT]) -> str:
"""[CE_QUE_FAIT_L_OUTIL_EN_UNE_PHRASE].
[QUAND_L_UTILISER]. [QUAND_NE_PAS_L_UTILISER].
[CE_QU_IL_RENVOIE_EXACTEMENT]. [SES_LIMITES].
Args:
[PARAM_1]: [DESCRIPTION_ET_FORMAT_ATTENDU].
[PARAM_2]: [DESCRIPTION_ET_VALEUR_PAR_DEFAUT].
"""
# 1. Valider les entrees
if not [CONDITION_DE_VALIDITE]:
return "[MESSAGE_D_ERREUR_QUI_DIT_QUOI_CORRIGER]"
# 2. Faire le travail
try:
resultat = [APPEL_METIER]
except [TYPE_D_EXCEPTION] as erreur:
logger.warning("[CONTEXTE] : %s", erreur)
return f"[MESSAGE_D_ERREUR] : {erreur}. [ACTION_DE_RATTRAPAGE_SUGGEREE]"
# 3. Limiter la taille avant de renvoyer
return json.dumps(resultat, ensure_ascii=False, default=str)Template : faire relire ton serveur par Claude Code
Relis mon serveur MCP dans [CHEMIN_DU_FICHIER].
Pour chaque outil expose, verifie :
1. La description dit-elle quand l'utiliser ET quand ne pas l'utiliser ?
2. La description annonce-t-elle ce qui est renvoye et les limites de taille ?
3. Les messages d'erreur disent-ils quoi corriger, ou juste que ca a echoue ?
4. Une entree malveillante peut-elle contourner le controle [NOM_DU_GARDE_FOU] ?
5. Y a-t-il un print() quelque part ? Le transport est stdio, donc c'est interdit.
Donne-moi les corrections sous forme de diff, sans reecrire tout le fichier.A retenir
- Le SDK Python est en v2 : la classe est
MCPServer, importee demcp.server.FastMCP, c'est le v1. MCPServergenere la definition de l'outil a partir de tes annotations de type et de ta docstring.- En transport stdio, jamais de
print(). Utiliselogging, qui ecrit surstderr. - La vraie securite d'un serveur de base, c'est le compte en lecture seule cote base. Le filtre par mots-cles n'est qu'une ceinture supplementaire.
- Limite toujours la taille des resultats et dis-le dans la description.
- Un message d'erreur qui explique quoi corriger permet au modele de se rattraper tout seul.
- Teste avec
npx @modelcontextprotocol/inspectoravant de brancher sur Claude Code.
Sources
- Construire un serveur MCP : tutoriel Python officiel
- SDK Python MCP : quickstart et migration v1 vers v2
- Specification des tools : nommage, schemas, erreurs
- Schema TypeScript officiel : interface ToolAnnotations
- MCP Inspector : outil de test des serveurs
- Ajouter un serveur MCP a Claude Code
- PrefectHQ/fastmcp - code d'exemple sur l'ecriture d'un serveur Python :
examples/echo.pytient en une dizaine de lignes