IAMaîtriser l'IA générative Plan du corpus
Accueil/MCP, le Model Context Protocol/Ecrire son propre serveur MCP en Python

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 uv en 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 | sh

Redemarre 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 desormais MCPServer, importee depuis mcp.server. Si tu tombes sur un tutoriel qui utilise FastMCP, c'est du v1. Le SDK recommande de garder une borne haute <2 dans tes dependances (par exemple mcp>=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.py

Il 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 httpx2 comme 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 :

  1. uv add psycopg[binary] et remplace sqlite3 par psycopg.
  2. Remplace _connexion() par une connexion qui utilise un compte technique en lecture seule (GRANT SELECT uniquement). C'est la vraie protection.
  3. Remplace la requete de list_tables par une lecture de information_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.py

Ou, 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 uv dans command. Trouve-le avec where uv sur Windows, which uv sur 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/inspector

Il 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 :

  1. Les trois outils apparaissent bien dans tools/list.
  2. La description de chaque outil est celle que j'ai ecrite.
  3. run_select avec DELETE FROM commandes renvoie bien un refus et ne casse pas le serveur.
  4. run_select sur 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

QuestionA 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 de mcp.server. FastMCP, c'est le v1.
  • MCPServer genere la definition de l'outil a partir de tes annotations de type et de ta docstring.
  • En transport stdio, jamais de print(). Utilise logging, qui ecrit sur stderr.
  • 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/inspector avant de brancher sur Claude Code.

Sources

Corpus personnel de formation · genere le 26/09/2026 · source : 04-ecrire-son-serveur-mcp.md