Auditer un serveur MCP qu’on n’a pas ecrit

Classifier les outils d’un catalogue MCP en CRUD generique vs verbes metier, et mesurer la distance de chaque outil au schema de persistance. Recycle la documentation du projet LivresAgites (en sommeil) vers le depot pedagogique CoursIA. Aucune donnee du site observe n’est reproduite : le catalogue est entierement synthetique (Maison Valmont).

La these

Un serveur MCP utile expose les verbes du metier, pas les tables de la base.

Cette these est facile a proclamer et difficile a verifier. Un catalogue MCP melange deux natures d’outils sans les etiqueter :

  • du CRUD generique (creer / lire / mettre a jour / supprimer des rangees de tables), qui donne a l’agent un pouvoir maximal et une comprehension minimale ;
  • des verbes metier (soumettre, assigner, faire passer d’un statut a un autre), qui restreignent le geste possible et y attachent le sens.

Les distinguer a l’oeil sur 90 outils est fastidieux et subjectif. Ce notebook construit la mesure : etant donne le inputSchema d’un outil, on estime sa distance au schema de persistance — un CRUD ressemble a une ligne de table, un verbe metier non. La separation en deux clusters est le resultat cherché.

Le catalogue de l’atelier : Maison Valmont

Maison Valmont est une maison d’edition fictive qui tourne sur un CMS generique. Son serveur MCP expose deux couches d’outils :

Couche Prefixe Nature Exemples
Plateforme (CMS + boutique) cms_, store_ CRUD sur les tables cms_create_post, store_list_orders
Metier editorial valmont_ Verbes du workflow valmont_submit_manuscript, valmont_assign_reviewer

La couche plateforme decrit le CMS ; la couche metier decrit l’edition. Un agent qui n’aurait que la premiere devrait reconstituer la logique editoriale a coups de requetes sur des tables — fragile et dangereux.

Note de methode. Ce catalogue est synthetique. Aucun outil, aucun nom de domaine, aucune prose ne provient d’une installation reelle. Pour auditer votre serveur MCP (AI-Engine ou autre), renseigner MCP_TOOL_CATALOG_URL dans .env — voir le fichier .env.example. Sans .env, le notebook utilise le catalogue embarque ci-dessous.

#dependances : stdlib uniquement (json, re, urllib). Aucun reseau requis.
import json, re, os
from collections import Counter
from urllib.request import Request, urlopen
from urllib.error import URLError

# --- Chargement du catalogue --------------------------------------------
# Deux sources, dans cet ordre :
#   1. Un endpoint live (optionnel) declare dans .env.
#   2. Le catalogue synthetique Maison Valmont embarque (defaut).
#
# On execute sur le defaut : aucun .env dans le depot, donc aucune donnee
# d'installation reelle n'entre dans les sorties committees.

def charger_catalogue():
    url = os.environ.get("MCP_TOOL_CATALOG_URL", "").strip()
    if url:
        token = os.environ.get("MCP_TOOL_TOKEN", "").strip()
        headers = {"Accept": "application/json"}
        if token:
            headers["Authorization"] = f"Bearer {token}"
        try:
            req = Request(url, headers=headers)
            with urlopen(req, timeout=10) as r:
                data = json.loads(r.read().decode("utf-8"))
            print(f"Catalogue charge depuis {url} : {len(data)} outils.")
            return normaliser(data)
        except (URLError, ValueError, OSError) as e:
            print(f"Endpoint live injoignable ({e}). Bascule sur le fixture.")
    print(f"Catalogue synthetique Maison Valmont : {len(CATALOGUE_VALMONT)} outils.")
    return normaliser(CATALOGUE_VALMONT)

def normaliser(outils_bruts):
    """Unifie le format : {name, description, params (dict name->type)}."""
    out = []
    for o in outils_bruts:
        schema = o.get("inputSchema", {}) or {}
        props = schema.get("properties", {}) or {}
        params = {p: meta.get("type", "?") for p, meta in props.items()}
        out.append({
            "name": o.get("name", "?"),
            "description": o.get("description", ""),
            "params": params,
        })
    return out

CATALOGUE_VALMONT = [
    # --- Couche plateforme : CRUD generique (CMS + boutique) ---
    {"name": "cms_create_post", "description": "Creer un article.",
     "inputSchema": {"type": "object", "properties": {
        "title": {"type": "string"}, "content": {"type": "string"},
        "status": {"type": "string"}, "author_id": {"type": "integer"},
        "slug": {"type": "string"}, "excerpt": {"type": "string"},
        "date": {"type": "string"}, "categories": {"type": "array"},
        "tags": {"type": "array"}}}},
    {"name": "cms_update_post", "description": "Modifier un article.",
     "inputSchema": {"type": "object", "properties": {
        "post_id": {"type": "integer"}, "title": {"type": "string"},
        "content": {"type": "string"}, "status": {"type": "string"},
        "slug": {"type": "string"}}}},
    {"name": "cms_get_post", "description": "Lire un article.",
     "inputSchema": {"type": "object", "properties": {"post_id": {"type": "integer"}}}},
    {"name": "cms_list_posts", "description": "Lister les articles.",
     "inputSchema": {"type": "object", "properties": {
        "status": {"type": "string"}, "author_id": {"type": "integer"},
        "limit": {"type": "integer"}, "offset": {"type": "integer"},
        "order": {"type": "string"}}}},
    {"name": "cms_delete_post", "description": "Supprimer un article.",
     "inputSchema": {"type": "object", "properties": {
        "post_id": {"type": "integer"}, "force": {"type": "boolean"}}}},
    {"name": "cms_list_users", "description": "Lister les comptes.",
     "inputSchema": {"type": "object", "properties": {
        "role": {"type": "string"}, "limit": {"type": "integer"},
        "offset": {"type": "integer"}}}},
    {"name": "cms_get_user", "description": "Lire un compte.",
     "inputSchema": {"type": "object", "properties": {"user_id": {"type": "integer"}}}},
    {"name": "cms_list_media", "description": "Lister les medias.",
     "inputSchema": {"type": "object", "properties": {
        "limit": {"type": "integer"}, "offset": {"type": "integer"},
        "mime_type": {"type": "string"}}}},
    {"name": "store_create_product", "description": "Creer un produit.",
     "inputSchema": {"type": "object", "properties": {
        "name": {"type": "string"}, "sku": {"type": "string"},
        "price": {"type": "number"}, "stock": {"type": "integer"},
        "description": {"type": "string"}, "categories": {"type": "array"},
        "weight": {"type": "number"}}}},
    {"name": "store_update_product", "description": "Modifier un produit.",
     "inputSchema": {"type": "object", "properties": {
        "product_id": {"type": "integer"}, "name": {"type": "string"},
        "price": {"type": "number"}, "stock": {"type": "integer"}}}},
    {"name": "store_list_orders", "description": "Lister les commandes.",
     "inputSchema": {"type": "object", "properties": {
        "status": {"type": "string"}, "customer_id": {"type": "integer"},
        "limit": {"type": "integer"}, "offset": {"type": "integer"}}}},
    {"name": "store_get_order", "description": "Lire une commande.",
     "inputSchema": {"type": "object", "properties": {"order_id": {"type": "integer"}}}},
    # --- Couche metier : verbes du workflow editorial ---
    {"name": "valmont_submit_manuscript", "description": "Deposer un manuscrit.",
     "inputSchema": {"type": "object", "properties": {
        "author_id": {"type": "integer"}, "title": {"type": "string"},
        "genre": {"type": "string"}, "synopsis": {"type": "string"},
        "manuscript_file": {"type": "string"}}}},
    {"name": "valmont_assign_reviewer", "description": "Assigner un relecteur.",
     "inputSchema": {"type": "object", "properties": {
        "manuscript_id": {"type": "integer"}, "reviewer_id": {"type": "integer"}}}},
    {"name": "valmont_submit_reading_report", "description": "Deposer un rapport de lecture.",
     "inputSchema": {"type": "object", "properties": {
        "manuscript_id": {"type": "integer"}, "reviewer_id": {"type": "integer"},
        "decision": {"type": "string", "enum": ["favorable", "reserve", "defavorable"]},
        "comment": {"type": "string"}}}},
    {"name": "valmont_transition_manuscript", "description": "Faire passer un manuscrit de statut.",
     "inputSchema": {"type": "object", "properties": {
        "manuscript_id": {"type": "integer"}, "from_status": {"type": "string"},
        "to_status": {"type": "string"}}}},
    {"name": "valmont_get_pending_decisions", "description": "Decisions en attente du comite.",
     "inputSchema": {"type": "object", "properties": {"committee_id": {"type": "integer"}}}},
    {"name": "valmont_get_manuscripts", "description": "Lister les manuscrits.",
     "inputSchema": {"type": "object", "properties": {
        "status": {"type": "string"}, "author_id": {"type": "integer"},
        "limit": {"type": "integer"}, "offset": {"type": "integer"}}}},
    {"name": "valmont_request_prereading", "description": "Demander une prelecture assistee.",
     "inputSchema": {"type": "object", "properties": {
        "manuscript_id": {"type": "integer"}, "environment": {"type": "string"}}}},
    {"name": "valmont_notify_author", "description": "Notifier l'autrice d'un evenement.",
     "inputSchema": {"type": "object", "properties": {
        "manuscript_id": {"type": "integer"}, "event": {"type": "string"}}}},
    {"name": "valmont_query_catalog", "description": "Interroger le catalogue indexe.",
     "inputSchema": {"type": "object", "properties": {
        "query": {"type": "string"}, "environment": {"type": "string"},
        "k": {"type": "integer"}}}},
    {"name": "valmont_get_processing_status", "description": "Etat du pipeline d'un manuscrit.",
     "inputSchema": {"type": "object", "properties": {
        "manuscript_id": {"type": "integer"},
        "pipeline_stage": {"type": "string",
            "enum": ["extraction", "chunking", "indexing", "prereading"]}}}},
]

OUTILS = charger_catalogue()
print(f"Total charge : {len(OUTILS)} outils.")
Catalogue synthetique Maison Valmont : 22 outils.
Total charge : 22 outils.

Premiere lecture : compter par prefixe

Le reflexe est de classer par prefixe. Donnons-lui raison sur les comptes, puis montrons sa limite.

Le prefixe est un indice, pas une definition. Un outil prefinance valmont_ peut tres bien etre un CRUD degenere ; un outil prefinance cms_ peut encapsuler une regle. Verifions.

def prefixe(nom):
    return nom.split("_", 1)[0] + "_"

comptes = Counter(prefixe(o["name"]) for o in OUTILS)
print("Outils par prefixe :")
for p, n in sorted(comptes.items(), key=lambda kv: -kv[1]):
    print(f"  {p:<12} {n}")

crud_prefixes = {"cms_", "store_"}
n_via_prefixe = sum(n for p, n in comptes.items() if p in crud_prefixes)
print(f"\nCRUD via prefixe (cms_/store_) : {n_via_prefixe} / {len(OUTILS)}")
print("Metier via prefixe (valmont_)  :", comptes.get("valmont_", 0))
Outils par prefixe :
  valmont_     10
  cms_         8
  store_       4

CRUD via prefixe (cms_/store_) : 12 / 22
Metier via prefixe (valmont_)  : 10

La vraie question : l’outil parle-t-il la base ou le metier ?

Compter par prefixe ne dit rien de l’interieur d’un outil. Ce qui distingue un CRUD d’un verbe metier, c’est la forme de son schema :

  • un CRUD expose les colonnes d’une table (beaucoup de champs libres : title, content, price, slug…). L’agent ecrit la ligne.
  • un verbe metier encapsule une regle et n’expose que ce que l’appelant doit decider (manuscript_id, decision, from_status/to_status). La regle vit dans l’outil, pas chez l’agent.

On mesure donc trois signaux tires du inputSchema :

  1. Presence de mots-clés workflow (decision, reviewer_id, from_status, to_status, environment, pipeline_stage…) : un CRUD n’en a aucun ; un verbe metier en a presque toujours.
  2. Largeur du schema : un CRUD create/update est large (5-9 champs inscriptibles) ; un verbe metier est etroit (2-4) parce qu’il cache la table.
  3. Identifiant d’entite generique (post_id, product_id, order_id…) : signal CRUD ; un verbe metier reference des concepts metier, pas des row ids.

On combine ces signaux en un score de distance au schema de persistance, entre 0 (tout-CRUD) et 1 (tout-metier).

# --- Vocabulaires de reference -----------------------------------------
# Parametres qui sentent la colonne de table (CRUD).
COLONNES = {
    "title", "content", "status", "slug", "excerpt", "date", "categories",
    "tags", "role", "limit", "offset", "order", "force", "mime_type",
    "name", "sku", "price", "stock", "description", "weight", "email",
    # identifiants de row generiques (un CRUD pointe une rangee).
    "post_id", "user_id", "product_id", "order_id", "comment_id", "media_id",
    "customer_id", "term_id",
}
# Parametres qui sentent le workflow (verbe metier).
WORKFLOW = {
    "decision", "reviewer_id", "from_status", "to_status", "committee_id",
    "environment", "event", "pipeline_stage", "genre", "synopsis",
    "manuscript_file", "query", "k", "manuscript_id", "author_id",
}
VERBES_CRUD = {"create", "update", "delete", "get", "list", "set", "edit",
               "add", "remove", "fetch"}
VERBES_METIER = {"submit", "assign", "approve", "reject", "request",
                 "transition", "notify", "query", "send", "schedule"}

def verbe(nom):
    return nom.split("_", 1)[1].split("_")[0].lower() if "_" in nom else ""

def signaux(outil):
    params = set(outil["params"])
    n = max(len(params), 1)
    v = verbe(outil["name"])
    return {
        "n": len(params),
        "verbe": v,
        "verb_crud": v in VERBES_CRUD,
        "verb_metier": v in VERBES_METIER,
        "kw_workflow": len(params & WORKFLOW),
        "large": len(params) >= 5,
        "id_entite": len(params & {"post_id", "user_id", "product_id",
                                   "order_id", "comment_id", "media_id",
                                   "customer_id"}),
        "overlap_colonnes": len(params & COLONNES) / n,
    }

def distance(outil):
    """Score [0,1]. Haut = metier (loin de la table) ; bas = CRUD."""
    s = signaux(outil)
    score = 0.0
    if s["verb_metier"]: score += 0.30
    if s["verb_crud"]:   score -= 0.30
    score += min(s["kw_workflow"], 2) * 0.20      # chaque kw workflow pousse vers le haut
    if s["id_entite"]:   score -= 0.20
    score += (1.0 - s["overlap_colonnes"]) * 0.25 # moins de colonnes -> plus loin
    if s["large"]:       score -= 0.10            # un schema large est une row a ecrire
    # mapage approximatif [-0.65, +0.75] -> [0, 1]
    d = (score + 0.65) / 1.40
    return max(0.0, min(1.0, d))

# Annotons chaque outil.
ANALYSE = []
for o in OUTILS:
    d = distance(o)
    ANALYSE.append({**o, "distance": d,
                    "classe": "metier" if d >= 0.5 else "CRUD"})
print("Classification calculee.")
Classification calculee.

Le resultat : deux clusters

Classons tous les outils par distance croissante et regardons la distribution. Si la these tient, le score separe le catalogue en deux groupes nets — avec, eventuellement, un cas limite interessant.

def barre(d, largeur=30):
    n = int(round(d * largeur))
    return "#" * n + "." * (largeur - n)

print(f"{'outil':<34} {'dist.':>6}  {'classe':<7}  courbe")
print("-" * 78)
for a in sorted(ANALYSE, key=lambda x: x["distance"]):
    print(f"{a['name']:<34} {a['distance']:>6.2f}  {a['classe']:<7}  {barre(a['distance'])}")

n_crud = sum(1 for a in ANALYSE if a["classe"] == "CRUD")
n_metier = sum(1 for a in ANALYSE if a["classe"] == "metier")
print(f"\nTotal : {n_crud} CRUD, {n_metier} metier.")
outil                               dist.  classe   courbe
------------------------------------------------------------------------------
cms_update_post                      0.04  CRUD     #.............................
cms_get_post                         0.11  CRUD     ###...........................
cms_delete_post                      0.11  CRUD     ###...........................
cms_get_user                         0.11  CRUD     ###...........................
store_update_product                 0.11  CRUD     ###...........................
store_list_orders                    0.11  CRUD     ###...........................
store_get_order                      0.11  CRUD     ###...........................
store_create_product                 0.18  CRUD     #####.........................
cms_list_users                       0.25  CRUD     ########......................
cms_list_media                       0.25  CRUD     ########......................
cms_create_post                      0.34  CRUD     ##########....................
cms_list_posts                       0.36  CRUD     ###########...................
valmont_get_manuscripts              0.44  CRUD     #############.................
valmont_get_pending_decisions        0.57  metier   #################.............
valmont_get_processing_status        0.71  metier   #####################.........
valmont_submit_manuscript            1.00  metier   ##############################
valmont_assign_reviewer              1.00  metier   ##############################
valmont_submit_reading_report        1.00  metier   ##############################
valmont_transition_manuscript        1.00  metier   ##############################
valmont_request_prereading           1.00  metier   ##############################
valmont_notify_author                1.00  metier   ##############################
valmont_query_catalog                1.00  metier   ##############################

Total : 13 CRUD, 9 metier.

Lecture

Les deux couches se separent proprement sur le score. Les outils cms_ et store_ tombent tous du cote CRUD ; les verbes valmont_ du cote metier.

Un cas attire l’attention : valmont_get_manuscripts. Malgre son prefixe metier, le classifieur le range du cote CRUD. A juste titre : son schema (status, author_id, limit, offset) est la signature exacte d’une operation de liste filtree sur une table — la meme forme que cms_list_posts ou store_list_orders. Le prefixe ment ; le schema, non.

C’est la leçon operationnelle :

Le prefixe d’un outil est une declaration d’intention. Sa distance au schema de persistance est un fait. Quand un agent choisit mal, c’est presque toujours parce qu’il a suivi le prefixe et non la forme.

Et la pire situation n’est pas l’outil mal classe — c’est l’absence de verbe metier la ou une regle devrait vivre. Si valmont_transition_manuscript n’existait pas, l’agent devrait faire cms_update_post sur le champ status, et rien ne l’empecherait de sauter un statut interdit. La distance au schema mesure aussi ce manque : un catalogue ou tout est a moins de 0,4 est un catalogue ou l’agent ecrit directement dans les tables — sans garde-fou.

La lecon, mesurée

La these de depart (« un serveur MCP utile expose les verbes du metier ») n’etait qu’une affirmation tant qu’elle reposait sur l’intuition. Mesurer la distance au schema de persistance en fait une propriete observable du catalogue :

  • elle est reproductible : deux auditeurs trouvent le meme classement sur le meme catalogue ;
  • elle est actionnable : un score < 0,3 sur l’ensemble du catalogue signale qu’aucune regle metier n’est encapsulee, et donc que l’agent est livre a lui-meme sur les transitions interdites ;
  • elle est portable : la methode s’applique a n’importe quel serveur MCP, n’importe quel CMS, n’importe quel langage — il suffit du inputSchema.

Le contre-pied vaut aussi : un catalogue ou tout serait un verbe metier (score > 0,8 partout) serait tout aussi suspect — il manquerait le CRUD elementaire dont un agent a besoin pour lire l’etat du monde. L’audit sain cherche un mix conscient, pas un extrême.

Exercices

Les exercices utilisent uniquement le catalogue Maison Valmont. Aucun reseau.

Exercice 1 – Classer sans le prefixe

Le prefixe est un indice trop commode. Ecrire classe_sans_prefixe(outil) qui renvoie "CRUD" ou "metier" en ignorant totalement le nom de l’outil — en se fondant uniquement sur outil["params"]. Verifier qu’elle range valmont_get_manuscripts du cote CRUD.

# Exercice 1 -- classifier un outil SANS regarder son nom.
def classe_sans_prefixe(outil):
    params = set(outil["params"])
    # TODO: decider CRUD ou metier a partir des parametres seuls.
    # Indice : la presence d'un mot-cle workflow est le signal le plus fort.
    return None  # <- remplacer

# Test rapide (doit rendre 'CRUD' pour l'imposteur et 'metier' pour un verbe).
# imposteur = prochain(o for o in OUTILS if o["name"] == "valmont_get_manuscripts")
# verbe = prochain(o for o in OUTILS if o["name"] == "valmont_assign_reviewer")
# print(classe_sans_prefixe(imposteur), classe_sans_prefixe(verbe))

Exercice 2 – Trouver l’imposteur

Parmi les outils prefixes valmont_, un seul a un schema de forme CRUD. Ecrire trouver_imposteur(catalogue) qui renvoie son nom, sans coder le nom en dure — en utilisant le score de distance.

# Exercice 2 -- identifier l'outil metier au schema CRUD.
def trouver_imposteur(catalogue):
    # TODO: parmi les outils dont le prefixe est metier (valmont_),
    # renvoyer celui dont la distance est la plus basse.
    return None  # <- remplacer

Exercice 3 – Concevoir un verbe metier

Le CRUD ne peut pas tout. Soit la regle :

Un manuscrit ne peut passer a l’etat en_relecture que s’il a au moins deux relecteurs assignes.

Cette regle doit vivre dans un outil metier, pas chez l’agent. Ecrire concevoir_ouvrir_relecture() qui renvoie un dict {"name", "inputSchema"} encodant ce verbe. L’agent ne doit pouvoir fournir que manuscript_id ; la verification des deux relecteurs est l’affaire de l’outil.

# Exercice 3 -- designer un verbe metier pour une regle de transition.
def concevoir_ouvrir_relecture():
    return {
        "name": "",  # <- nom du verbe
        "inputSchema": {"type": "object", "properties": {
            # TODO: les parametres que l'agent PEUT fournir.
        }},
    }

Exercice 4 – Auditer la derive d’un catalogue

Une refonte de serveur MCP est un moment dangereux : des outils disparaissent, d’autres apparaissent, d’autres sont renommes — et l’agent qui comptait sur l’ancien nom casse silencieusement. Le travail de l’auditeur ne s’arrete pas au catalogue figé : il produit le rapport de derive entre deux versions — ajoutes, retires, et les candidats renomes (un retire et un ajoute qui partagent leurs parametres).

Indice de validation : dans le jeu d’essai commente ci-dessous, valmont_get_manuscripts doit ressortir comme un renommage, pas comme un retrait suivi d’un ajout sans lien.

# Exercice 4 -- produire le rapport de derive entre deux versions du catalogue.
def diff_catalogue(v1, v2):
    # v1, v2 : listes d'outils [{"name": ..., "params": [...]}, ...]
    # TODO: renvoyer {"ajoutes": [...], "retires": [...], "renomes": [(ancien, nouveau), ...]}
    # Indice : ajoutes / retires = difference ensembliste sur les noms.
    # Indice : un retire et un ajoute qui partagent leurs parametres = candidat renomme.
    return None  # <- remplacer

# Test rapide (jeu d'essai factice, a decommenter) :
# V1 = [{"name": "valmont_get_manuscripts", "params": ["id"]},
#       {"name": "valmont_list_authors", "params": []}]
# V2 = [{"name": "valmont_fetch_manuscripts", "params": ["id"]},
#       {"name": "valmont_list_authors", "params": []},
#       {"name": "valmont_assign_reviewer", "params": ["id", "reviewer"]}]
# r = diff_catalogue(V1, V2)
# print(r["ajoutes"], r["retires"], r["renomes"])
# attendu : ['valmont_assign_reviewer'] | [] | [('valmont_get_manuscripts', 'valmont_fetch_manuscripts')]

Provenance et pour aller plus loin

Provenance. Catalogue entierement synthetique (Maison Valmont). Aucune donnee d’installation reelle : ni nom d’outil reel, ni titre d’ouvrage, ni prose sous droits, ni identifiant. Les sorties de ce notebook proviennent du fixture embarque ; elles sont reproductibles sans reseau ni cle. Les chiffres (nombres d’outils par couche, scores de distance) decrivent un atelier fictif et ne se transportent pas a une installation reelle — ce qui s’enseigne est la methode de classification.

Ce que ce notebook ne dit pas. La distance au schema est une heuristic lue sur le inputSchema. Elle ne saisit pas une regle encodee dans le corps de l’outil (un CRUD pourrait cacher une regle cote serveur ; un verbe metier pourrait n’etre qu’une coquille vide). L’audit par le schema est un premier filtre ; il trouve les catalogues qui manquent de verbes metier, il ne prouve pas que les verbes presents sont correctement implementes.

Pour aller plus loin.

Retour au sommet