Écrire deux serveurs MCP en Python
Un serveur MCP en dix lignes
Avec le SDK mcp 2.x, un serveur est un objet MCPServer ; chaque fonction décorée par @mcp.tool() devient un outil. Le SDK lit la signature et la docstring pour produire le schéma d'entrée (JSON Schema) annoncé au client.
from mcp.server.mcpserver import MCPServer
from mcp.types import ToolAnnotations
mcp = MCPServer("commandes", instructions="Base de commandes d'une boutique de vélos fictive.")
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def lister_tables() -> dict:
"""Liste les tables de la base et leurs colonnes (nom et type)."""
...
if __name__ == "__main__":
mcp.run() # transport stdio par défautLes annotations (readOnlyHint, destructiveHint… dans la spécification ; en Python, le SDK 2.x les nomme read_only_hint, destructive_hint) décrivent le comportement d'un outil au client. La spécification MCP les présente comme des indications : un client ne doit pas leur faire confiance si le serveur n'est pas de confiance. Ici, c'est nous qui écrivons le serveur : on peut s'en servir pour filtrer (voir la leçon suivante).
La base de commandes
creer_base() crée une petite base fictive : 4 clients, 5 produits, 8 commandes (tables clients, produits, commandes). L'outil principal exécute une requête en lecture seule :
def _lecture_seule() -> sqlite3.Connection:
if not DB.exists():
creer_base()
# mode=ro : même si une requête d'écriture passait le filtre, SQLite la refuserait.
return sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def executer_select(sql: str) -> dict:
"""Exécute UNE requête SQL SELECT (SQLite) en lecture seule et renvoie au plus 50 lignes.
Args:
sql: une requête SELECT ou WITH ... SELECT. Toute autre instruction est refusée.
"""
requete = sql.strip().rstrip(";")
if ";" in requete or not re.match(r"(?is)^\s*(select|with)\b", requete):
return {"erreur": "Seules les requêtes SELECT uniques sont autorisées."}
try:
with _lecture_seule() as c:
cur = c.execute(requete)
lignes = cur.fetchmany(MAX_LIGNES + 1)
return {"colonnes": [d[0] for d in cur.description], "lignes": lignes[:MAX_LIGNES],
"tronque": len(lignes) > MAX_LIGNES}
except sqlite3.Error as e:
return {"erreur": f"SQLite : {e}"} # renvoyée au modèle, qui peut corriger sa requêteTrois choix à retenir :
| Choix | Pourquoi |
|---|---|
Erreur renvoyée comme résultat ({"erreur": ...}) | le modèle lit le message et corrige sa requête au tour suivant, au lieu d'un échec brutal |
fetchmany(51) puis tronque | le modèle sait qu'il manque des lignes et peut affiner (agrégat, LIMIT) |
Pas de ; interne | execute refuse déjà plusieurs instructions, mais en Python 3.10 il lève sqlite3.Warning, qui n'hérite pas de sqlite3.Error : sans ce filtre, l'erreur ne serait pas convertie en message lisible |
Le serveur expose aussi annuler_commande(commande_id), annoté destructive_hint=True. Il existe pour d'autres clients (un back-office, par exemple) ; notre agent ne le recevra pas.
Le modèle envoie « SELECT * FROM commande » (table inexistante). Que se passe-t-il ?
L'exception sqlite3.Error est convertie en résultat lisible. Le modèle voit l'erreur dans la réponse de l'outil, appelle souvent lister_tables, puis réessaie avec le bon nom.
Le serveur de fichiers confiné
RACINE = Path(os.environ.get("DOCS_ROOT", Path(__file__).with_name("docs"))).resolve()
EXTENSIONS = {".md", ".txt", ".csv"}
def _chemin_sur(nom: str) -> Path:
"""Résout `nom` sous RACINE et refuse toute sortie du dossier (../, chemin absolu, lien)."""
chemin = (RACINE / nom).resolve()
if not chemin.is_relative_to(RACINE):
raise ValueError(f"Accès refusé hors de {RACINE.name}/ : {nom}")
if chemin.suffix not in EXTENSIONS:
raise ValueError(f"Extension non autorisée : {chemin.suffix or '(aucune)'}")
return chemin
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def lire_document(nom: str) -> str:
"""Renvoie le contenu texte d'un document du dossier partagé."""
return _chemin_sur(nom).read_text(encoding="utf-8")[:TAILLE_MAX]flowchart TB
N["nom demandé par le modèle"] --> R["(RACINE / nom).resolve()"]
R --> Q{"sous RACINE ?"}
Q -->|"non : ../.env, /etc/passwd, lien"| X["ValueError : accès refusé"]
Q -->|"oui"| E{"extension .md .txt .csv ?"}
E -->|"non"| X
E -->|"oui"| L["lecture, 20 000 caractères max"]
resolve() transforme le chemin en chemin absolu après avoir suivi les .. et les liens symboliques : c'est ce qui rend la comparaison fiable. Comparer des chaînes (nom.startswith("docs/")) ne suffirait pas : docs/../.env commence bien par docs/.
Quels noms sont refusés par _chemin_sur ? (plusieurs réponses)
../agent.py sort du dossier et a une extension .py ; /etc/passwd est un chemin absolu hors racine. Un fichier .txt d'un sous-dossier de docs/ reste sous la racine et est autorisé.
Exercice : un outil de recherche
Ajoutez à fichiers_server.py un outil chercher(mot: str) -> list[dict] qui renvoie, pour chaque document contenant le mot (sans tenir compte de la casse), son nom et la première ligne qui le contient. Il doit réutiliser les mêmes protections.
Voir la solution
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def chercher(mot: str) -> list[dict]:
"""Cherche un mot (insensible à la casse) dans les documents et renvoie la première ligne trouvée."""
resultats = []
for nom in lister_documents():
for ligne in _chemin_sur(nom).read_text(encoding="utf-8").splitlines():
if mot.lower() in ligne.lower():
resultats.append({"document": nom, "ligne": ligne.strip()[:200]})
break
return resultats[:20]Passer par lister_documents() puis _chemin_sur() garantit qu'on ne lit que des fichiers autorisés. N'oubliez pas d'ajouter un test (chercher("30 jours") doit trouver politique-retours.md).
Résultat obtenu
Tests des garde-fous du 2026-10-15 (appel direct des fonctions, sans serveur ni clé) :
test_mcp.py::test_select_autorise PASSED
test_mcp.py::test_ecritures_refusees[DELETE FROM commandes] PASSED
test_mcp.py::test_ecritures_refusees[SELECT 1; DROP TABLE clients] PASSED
test_mcp.py::test_ecritures_refusees[UPDATE commandes SET statut='x'] PASSED
test_mcp.py::test_connexion_en_lecture_seule PASSED
test_mcp.py::test_fichiers_hors_dossier_refuses[../agent.py] PASSED
test_mcp.py::test_fichiers_hors_dossier_refuses[/etc/passwd] PASSED
test_mcp.py::test_fichiers_hors_dossier_refuses[../../.env] PASSED
test_mcp.py::test_lecture_document PASSEDJe sais faire
- Écrire un serveur MCP avec
MCPServeret@mcp.tool()(SDKmcp2.x) - Renvoyer les erreurs comme résultats lisibles par le modèle
- Ouvrir SQLite en lecture seule avec
mode=ro - Confiner un accès fichiers avec
resolve()etis_relative_to()