Aller au contenu

S'exercerNiveau 3, Construire120 minutes

Projet : un serveur MCP sur un logiciel de suivi des clients fictif

Un fichier de suivi des clients fabriqué, vingt clients et prospects, et un serveur MCP qui le lit et le met à jour : deux outils qui lisent, deux qui écrivent, aucun qui supprime, et un journal de chaque écriture. Exécuté par Meridal avec le SDK Python officiel, sans modèle ; puis branché sur un assistant. Données fabriquées, critères de réussite et correction commentée.

Écrit le 9 octobre 2026 · Vérifié le 9 octobre 2026

Vous saurez

  • Écrire un serveur MCP qui écrit, et pas seulement qui lit
  • Marquer chaque outil : lecture, écriture, écriture destructive
  • Refuser une écriture mal formée, et consigner chaque écriture acceptée
  • Tester le serveur sur une copie des données, sans modèle

Dans le parcours

Progression par projets

Étape 9 sur 12

Sur cette page
  1. L'essentiel
  2. Objectif
  3. Données fournies
  4. Consignes
  5. Critères de réussite
  6. Indications
  7. Correction commentée
  8. Extensions
  9. Lectures suivantes
L'essentiel
  1. Un serveur qui écrit se conçoit outil par outil : ce que chacun peut changer, ce qu'il refuse, ce qu'il consigne. Il n'a pas d'outil pour ce qui ne doit jamais arriver, ici la suppression.
  2. La spécification MCP demande au serveur de valider toutes les entrées, de contrôler l'accès, de limiter les appels et de nettoyer les sorties ; au client, de demander confirmation pour les opérations sensibles et de journaliser les appels.
  3. Les annotations, lecture seule ou destructive, renseignent l'assistant ; elles ne protègent rien : la spécification demande de les tenir pour non fiables, sauf si le serveur l'est.
  4. Chaque écriture laisse une ligne au journal, avec l'avant et l'après : c'est ce qui permet de revenir en arrière.
  5. On teste le serveur sans modèle, sur une copie du fichier : chaque appel est celui qu'un modèle pourrait demander, y compris les mauvais.
1

Objectif

Étendre le serveur de la page du serveur MCP, qui ne faisait que lire, à un vrai fichier de suivi des clients : chercher, lister ceux à relancer, noter un contact, changer un statut. Le tester sur une copie, puis le brancher sur Claude Code ou Claude Desktop et lui faire faire la même chose en langage courant.

Données fabriquées pour l'exercice : les sociétés, les villes, les adresses en « .example » et les dates sont inventées.

2

Données fournies

Un fichier crm.csv : vingt clients et prospects, chacun avec sa ville, son contact, son statut, la date du dernier contact, celle de la prochaine action et la personne qui le suit. Les six premières lignes :

crm.csv
id;societe;ville;contact;statut;dernier_contact;prochaine_action;responsable
C-001;Société A;Lyon;compta@societe-a.example;client;2026-09-12;2026-10-12;Claire
C-002;Société B;Nantes;factures@societe-b.example;client;2026-09-30;2026-10-07;Karim
C-003;Société C;Lille;admin@societe-c.example;client;2026-08-21;2026-11-02;Claire
C-004;Société D;Rennes;achats@societe-d.example;prospect;2026-10-01;2026-10-08;Élodie
C-005;Société E;Bordeaux;compta@societe-e.example;client;2026-07-30;2026-10-05;Karim
C-006;Société F;Nantes;contact@societe-f.example;prospect;2026-09-25;2026-10-15;Élodie
Voir les vingt lignes
crm.csv
id;societe;ville;contact;statut;dernier_contact;prochaine_action;responsable
C-001;Société A;Lyon;compta@societe-a.example;client;2026-09-12;2026-10-12;Claire
C-002;Société B;Nantes;factures@societe-b.example;client;2026-09-30;2026-10-07;Karim
C-003;Société C;Lille;admin@societe-c.example;client;2026-08-21;2026-11-02;Claire
C-004;Société D;Rennes;achats@societe-d.example;prospect;2026-10-01;2026-10-08;Élodie
C-005;Société E;Bordeaux;compta@societe-e.example;client;2026-07-30;2026-10-05;Karim
C-006;Société F;Nantes;contact@societe-f.example;prospect;2026-09-25;2026-10-15;Élodie
C-007;Société G;Toulouse;daf@societe-g.example;client;2026-09-18;2026-10-09;Claire
C-008;Société H;Angers;direction@societe-h.example;perdu;2026-06-02;;Karim
C-009;Société I;Lyon;achats@societe-i.example;prospect;2026-10-06;2026-10-20;Élodie
C-010;Société J;Tours;contact@societe-j.example;client;2026-09-02;2026-10-01;Claire
C-011;Société K;Nantes;compta@societe-k.example;client;2026-09-28;2026-11-15;Karim
C-012;Société L;Brest;contact@societe-l.example;prospect;2026-09-15;2026-10-06;Élodie
C-013;Société M;Caen;achats@societe-m.example;client;2026-08-11;2026-10-30;Claire
C-014;Société N;Metz;direction@societe-n.example;perdu;2026-05-19;;Karim
C-015;Société O;Dijon;contact@societe-o.example;prospect;2026-10-02;2026-10-09;Élodie
C-016;Société P;Lyon;compta@societe-p.example;client;2026-09-22;2026-12-01;Claire
C-017;Société Q;Rouen;achats@societe-q.example;client;2026-09-08;2026-10-08;Karim
C-018;Société R;Nice;contact@societe-r.example;prospect;2026-09-29;2026-10-27;Élodie
C-019;Société S;Reims;daf@societe-s.example;client;2026-08-27;2026-10-16;Claire
C-020;Société T;Nantes;contact@societe-t.example;prospect;2026-10-05;2026-10-12;Karim
3

Consignes

Quatre outils, et aucun pour supprimer

Consignes
OutilNatureCe qu'il faitCe qu'il refuse
chercher_clientslitle nom ou la ville contient un textemoins de deux caractères
a_relancerlitla prochaine action est due à une dateune date mal formée
noter_contactécritla date du contact et la prochaine actionune prochaine action avant le contact ; un client inconnu
changer_statutécrit, destructifprospect, client ou perdu, avec un motifun statut inconnu ; un motif de moins de dix caractères ; un client inconnu
La nature est déclarée par les annotations de chaque outil ; elle aide l'assistant à demander confirmation, elle ne remplace ni les refus du serveur ni le journal.
  1. Préparez le projet : uv init suivi-clients, puis uv add "mcp[cli]". Copiez crm.csv, serveur.py et essai.py.
  2. Lisez le serveur avant de le lancer : pour chaque outil, ce qu'il peut changer et ce qu'il refuse.
  3. Lancez l'essai : uv run essai.py. Il copie le fichier dans un dossier temporaire, lance le serveur dessus, appelle les quatre outils, dont trois fois de travers, puis affiche le journal.
  4. Comparez à la correction, ligne par ligne.
  5. Branchez le serveur sur votre assistant : avec Claude Code, claude mcp add suivi-clients -- uv --directory /chemin/absolu/suivi-clients run serveur.py ; avec Claude Desktop, la déclaration de la page du serveur MCP.
  6. Demandez-lui, en langage courant : « Quels clients faut-il relancer aujourd'hui ? », puis « Note que j'ai eu la Société B au téléphone ce matin, à rappeler dans deux semaines. » Regardez quels outils il appelle, avec quels arguments, et s'il vous demande confirmation avant d'écrire.
serveur.py
"""Un serveur MCP sur un fichier de suivi des clients : deux outils qui lisent, deux qui écrivent,
aucun qui supprime. Chaque écriture est consignée dans journal.csv. Données fabriquées."""
import csv
import logging
import os
from datetime import date, datetime
from pathlib import Path
from typing import TypedDict

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

DOSSIER = Path(os.environ.get("CRM_DOSSIER", Path(__file__).parent))
CHAMPS = ["id", "societe", "ville", "contact", "statut", "dernier_contact", "prochaine_action", "responsable"]
STATUTS = {"prospect", "client", "perdu"}
logging.basicConfig(level=logging.INFO)          # les journaux techniques vont sur stderr
mcp = MCPServer("Suivi des clients")
LIRE = ToolAnnotations(read_only_hint=True)

class Fiche(TypedDict):
    id: str
    societe: str
    ville: str
    contact: str
    statut: str
    dernier_contact: str
    prochaine_action: str
    responsable: str

def _lire() -> list[dict]:
    with open(DOSSIER / "crm.csv", encoding="utf-8", newline="") as f:
        return list(csv.DictReader(f, delimiter=";"))

def _ecrire(lignes: list[dict]) -> None:
    provisoire = DOSSIER / "crm.csv.tmp"          # écrire à côté, puis remplacer : jamais de fichier à moitié écrit
    with open(provisoire, "w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=CHAMPS, delimiter=";")
        w.writeheader()
        w.writerows(lignes)
    provisoire.replace(DOSSIER / "crm.csv")

def _journal(outil: str, client: str, avant: str, apres: str) -> None:
    neuf = not (DOSSIER / "journal.csv").exists()
    with open(DOSSIER / "journal.csv", "a", encoding="utf-8", newline="") as f:
        w = csv.writer(f, delimiter=";")
        if neuf:
            w.writerow(["horodatage", "outil", "client", "avant", "apres"])
        w.writerow([datetime.now().isoformat(timespec="seconds"), outil, client, avant, apres])

def _fiche(lignes: list[dict], id_client: str) -> dict:
    for l in lignes:
        if l["id"] == id_client:
            return l
    raise ToolError(f"Aucun client {id_client}.")

@mcp.tool(annotations=LIRE)
def chercher_clients(texte: str) -> list[Fiche]:
    """Cherche les clients dont le nom ou la ville contient ce texte.

    Args:
        texte: Un morceau du nom de la société ou de la ville, par exemple Nantes.
    """
    t = texte.strip().lower()
    if len(t) < 2:
        raise ToolError("Donnez au moins deux caractères.")
    return [l for l in _lire() if t in l["societe"].lower() or t in l["ville"].lower()]

@mcp.tool(annotations=LIRE)
def a_relancer(au: str) -> list[Fiche]:
    """Les clients et prospects dont la prochaine action est due à cette date ou avant.

    Args:
        au: La date de référence, au format AAAA-MM-JJ.
    """
    try:
        jour = date.fromisoformat(au)
    except ValueError:
        raise ToolError("Date attendue au format AAAA-MM-JJ.")
    return [l for l in _lire() if l["prochaine_action"] and date.fromisoformat(l["prochaine_action"]) <= jour]

@mcp.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False))
def noter_contact(id_client: str, date_contact: str, prochaine_action: str) -> Fiche:
    """Enregistre un contact avec un client : la date du contact et celle de la prochaine action.
    N'efface rien d'autre que ces deux dates ; l'ancienne valeur reste dans le journal.

    Args:
        id_client: L'identifiant du client, par exemple C-002.
        date_contact: La date du contact, AAAA-MM-JJ, aujourd'hui ou avant.
        prochaine_action: La date de la prochaine action, AAAA-MM-JJ, après le contact.
    """
    try:
        d, p = date.fromisoformat(date_contact), date.fromisoformat(prochaine_action)
    except ValueError:
        raise ToolError("Dates attendues au format AAAA-MM-JJ.")
    if p <= d:
        raise ToolError("La prochaine action doit venir après le contact.")
    lignes = _lire()
    f = _fiche(lignes, id_client)
    avant = f"{f['dernier_contact']} / {f['prochaine_action']}"
    f["dernier_contact"], f["prochaine_action"] = date_contact, prochaine_action
    _ecrire(lignes)
    _journal("noter_contact", id_client, avant, f"{date_contact} / {prochaine_action}")
    return f

@mcp.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=True))
def changer_statut(id_client: str, statut: str, motif: str) -> Fiche:
    """Change le statut d'un client : prospect, client ou perdu. Le motif est obligatoire.

    Args:
        id_client: L'identifiant du client, par exemple C-004.
        statut: Le nouveau statut : prospect, client ou perdu.
        motif: Pourquoi, en une phrase ; il est consigné dans le journal.
    """
    if statut not in STATUTS:
        raise ToolError("Statut attendu : prospect, client ou perdu.")
    if len(motif.strip()) < 10:
        raise ToolError("Donnez un motif d'au moins dix caractères.")
    lignes = _lire()
    f = _fiche(lignes, id_client)
    avant = f["statut"]
    f["statut"] = statut
    if statut == "perdu":
        f["prochaine_action"] = ""
    _ecrire(lignes)
    _journal("changer_statut", id_client, avant, f"{statut} : {motif.strip()}")
    return f

if __name__ == "__main__":
    mcp.run(transport="stdio")
essai.py
"""Lance le serveur sur une copie du fichier, comme le ferait un assistant, et appelle ses outils.
Aucun modèle : chaque appel est celui qu'un modèle pourrait demander."""
import asyncio
import os
import shutil
import sys
import tempfile
from pathlib import Path

from mcp import Client, StdioServerParameters

async def main() -> None:
    copie = Path(tempfile.mkdtemp())
    shutil.copy("crm.csv", copie / "crm.csv")                  # le fichier d'origine ne bouge pas
    serveur = StdioServerParameters(command=sys.executable, args=["serveur.py"],
                                    env={**os.environ, "CRM_DOSSIER": str(copie)})
    async with Client(serveur) as client:
        outils = (await client.list_tools()).tools
        for o in outils:
            a = o.annotations
            nature = "lit" if a and a.read_only_hint else ("écrit, destructif" if a and a.destructive_hint else "écrit")
            print(f"Outil {o.name} : {nature}")
        nantes = await client.call_tool("chercher_clients", {"texte": "Nantes"})
        print("Clients à Nantes :", ", ".join(c["societe"] for c in nantes.structured_content["result"]))
        dus = await client.call_tool("a_relancer", {"au": "2026-10-09"})
        print("À relancer au 9 octobre :", ", ".join(c["id"] for c in dus.structured_content["result"]))
        r = await client.call_tool("noter_contact", {"id_client": "C-002", "date_contact": "2026-10-09", "prochaine_action": "2026-10-23"})
        print("Contact noté :", r.structured_content["id"], r.structured_content["prochaine_action"])
        for args in ({"id_client": "C-004", "statut": "client", "motif": "ok"},
                     {"id_client": "C-004", "statut": "fidèle", "motif": "Premier bon de commande reçu le 9 octobre."},
                     {"id_client": "C-099", "statut": "client", "motif": "Premier bon de commande reçu le 9 octobre."}):
            refus = await client.call_tool("changer_statut", args)
            print("Refusé :", refus.content[0].text if refus.is_error else "accepté")
        r = await client.call_tool("changer_statut", {"id_client": "C-004", "statut": "client", "motif": "Premier bon de commande reçu le 9 octobre."})
        print("Statut changé :", r.structured_content["id"], r.structured_content["statut"])
    print("Journal :")
    for ligne in (copie / "journal.csv").read_text(encoding="utf-8").splitlines()[1:]:
        print("  " + ligne.split(";", 1)[1])                      # sans l'horodatage, qui change à chaque essai
    shutil.rmtree(copie)

asyncio.run(main())
4

Critères de réussite

5

Indications

L'assistant ne voit pas le serveur

CauseLe chemin n'est pas absolu, ou uv n'est pas trouvé par l'assistant.

À faireUn chemin absolu dans la commande ; le chemin complet de uv au besoin.

Le serveur s'arrête dès qu'on l'appelle

CauseUn print, ou un message, sort sur la sortie standard, réservée au protocole.

À faireLes journaux par logging, qui écrit sur stderr ; aucun print dans le serveur.

Le modèle note « dans deux semaines » à la mauvaise date

CauseIl calcule la date lui-même, sans savoir quel jour on est.

À faireDonnez la date dans la demande, ou ajoutez un outil qui la donne ; le serveur refuse déjà une date avant le contact.

Le fichier est vide ou coupé après une erreur

CauseUne écriture interrompue au milieu.

À faireÉcrire dans un fichier provisoire, puis le renommer : c'est ce que fait _ecrire.

Deux personnes écrivent en même temps

CauseUn fichier CSV ne gère pas deux écritures simultanées.

À faireUn fichier suffit pour l'exercice ; en vrai, une base de données ou l'API du logiciel de suivi.

Le modèle demande à supprimer un client

CauseRien ne l'en empêche dans la conversation.

À faireIl n'y a pas d'outil pour cela : la demande échoue ; c'est voulu.

6

Correction commentée

Ouvrir la correction
La sortieuv run essai.py
Outil chercher_clients : litOutil a_relancer : litOutil noter_contact : écritOutil changer_statut : écrit, destructifClients à Nantes : Société B, Société F, Société K, Société TÀ relancer au 9 octobre : C-002, C-004, C-005, C-007, C-010, C-012, C-015, C-017Contact noté : C-002 2026-10-23Refusé : Error executing tool changer_statut: Donnez un motif d'au moins dix caractères.Refusé : Error executing tool changer_statut: Statut attendu : prospect, client ou perdu.Refusé : Error executing tool changer_statut: Aucun client C-099.Statut changé : C-004 clientJournal :  noter_contact;C-002;2026-09-30 / 2026-10-07;2026-10-09 / 2026-10-23  changer_statut;C-004;prospect;client : Premier bon de commande reçu le 9 octobre.

Exécuté le 9 octobre 2026 avec Python 3.12.7 et mcp 2.3.0, sans modèle, sur une copie du fichier ; les horodatages du journal sont retirés de l'affichage.

Les huit clients à relancer. Ceux dont la prochaine action tombe le 9 octobre ou avant ; les deux clients perdus n'ont plus de prochaine action, ils n'apparaissent pas.

Les trois refus. Un motif trop court, un statut inconnu, un client inexistant : le serveur refuse avant d'ouvrir le fichier en écriture. Le message part au modèle, qui peut corriger sa demande.

Le journal. Deux lignes, l'avant et l'après de chaque écriture acceptée. Pour défaire le changement de statut de C-004, il suffit de relire sa ligne.

La sortieClaude Code : « Quels clients faut-il relancer aujourd'hui ? »

Pas encore exécuté par Meridal. Ce qu'on attend : l'assistant appelle a_relancer avec la date du jour, puis cite les clients ; il ne lit pas le fichier par un autre chemin.

Ce qui compte le plus. Ce que le serveur refuse. Un modèle demandera un jour une écriture absurde ; c'est le serveur, pas la consigne donnée au modèle, qui l'arrête.

7

Extensions

  • Le vrai logiciel : remplacer le fichier par l'API de votre logiciel de suivi des clients, avec un compte de service aux droits minimaux (les connecteurs).
  • À distance : le même serveur en HTTP, pour toute l'équipe, avec une authentification ; la spécification la décrit pour ce transport.
  • Un agent qui s'en sert : un agent qui prépare les relances à partir de a_relancer, et une personne qui valide (un agent en entreprise de bout en bout).
  • Les attaques : un client dont la fiche contient une consigne cachée ; le modèle doit la lire comme une donnée (la sécurité d'une application d'IA).
8

Lectures suivantes

Limites et sources

Le fichier et les sociétés sont fabriqués ; le serveur et son essai ont été exécutés par Meridal, sans modèle ; le branchement sur un assistant n'a pas encore été exécuté.

La spécification citée est la révision du 28 juillet 2026 ; le SDK, mcp 2.3.0, publié le 2 octobre 2026.

  1. MCP, les outils et leur sécurité ; le SDK Python officiel ; Claude Code, MCP ; MCP, construire un serveur.
  2. Le code et les données : apprendre/pages/projets/projet-mcp-crm/code/.

Écrit par Meridal, avec l'aide de l'IA ; chaque fait vérifié à sa source et relu avant publication. Texte et schémas sous licence CC BY 4.0 : reprenez-les en citant Meridal. Le code sous licence MIT.

Service correspondant

Un assistant qui connaît votre entreprise

3 300 € à 10 200 €, puis sur devis

Voir le service

Un premier échange de 30 minutes

En visio et sans frais : vous décrivez votre situation, nous vous indiquons ce qui est réalisable, avec quels outils et pour quel ordre de prix. Vous pouvez aussi nous écrire ; chaque message reçoit une réponse écrite.