Type
Guide
Publié le
Durée
7 min de lecture
Auteur
rootzero
Read in English
Read in English →

MCP : concevoir un serveur d’outils précis, borné et autorisé

Un serveur MCP vaut le coût si ses outils décrivent des actions précises, des sorties bornées et une autorisation réelle. Méthode sur un exemple de lecture fictif.

  • IA
  • MCP
  • Méthodologie

La première question est : « quelle décision l’assistant doit-il prendre avec cet outil ? » Si un opérateur sait déjà lancer une commande et lire sa sortie, un serveur MCP ajoute du code et des permissions. Il devient intéressant lorsque plusieurs conversations ont besoin de la même capacité, avec des arguments précis et des appels observables. Son intérêt est une interface stable entre une intention et une API.

Le lab 2.3 du cours SEC565 prend Empire et Cobalt Strike comme terrain d’intégration : sélectionner des routes d’API, les renommer et rendre les réponses exploitables. Il figure encore « à faire » dans notre journal local ; nous ne prétendons pas avoir piloté un agent C2 par MCP. L’exemple ci-dessous utilise un catalogue fictif de sessions d’analyse, en lecture seule. Il montre ce qu’il faut décider avant de choisir une bibliothèque.

Si le CLI répond déjà au besoin, gardez-le

MCP sépare le host (l’application qui orchestre le modèle), le client qu’il crée et le serveur qui expose des capacités. Le serveur ne parle pas directement au modèle ; le host décide ce qu’il lui montre. Un outil MCP porte un nom, une description et un schéma d’entrée. Le modèle peut le découvrir et demander son exécution. Une ressource est plutôt un contenu que l’application décide de charger ; un prompt est un modèle de message choisi par l’utilisateur. Cette distinction évite de transformer tout fichier ou toute route HTTP en « commande ». (Architecture MCP, guide du SDK Python)

Un script local lancé une fois par un analyste n’a pas besoin de ce protocole. Un catalogue que plusieurs assistants doivent interroger, avec les mêmes filtres et le même format de réponse, peut en profiter. La comparaison utile est concrète : pour une question donnée, l’assistant choisit-il la bonne fonction, avec les bons paramètres, et cite-t-il la bonne session ? Si le même travail au CLI est plus simple et aussi fiable, le pont MCP ne justifie pas son entretien.

Exposer list_sessions et get_session

Imaginons un dépôt de sessions factices. Un enregistrement contient un identifiant opaque, un horodatage, un état et une courte description. L’API sous-jacente sait aussi supprimer et exporter ; notre serveur n’expose ni l’un ni l’autre. Nous commencerions par list_sessions(status, cursor, limit) pour trouver des identifiants, puis get_session(session_id) pour lire un seul dossier. Le contrat peut être écrit sans SDK :

list_sessions(status?: "open" | "closed", cursor?: string, limit: 1..20)
  -> {items: [{id, started_at, status, summary}], next_cursor: string | null}

get_session(session_id: string)
  -> {id, started_at, status, summary, evidence_refs: string[]}

session_id désigne un objet, jamais un chemin ou un morceau de commande ; evidence_refs contient des références, pas les journaux entiers. Le serveur vérifie limit avant l’appel à l’API. Identifiant inconnu, API indisponible et refus d’accès doivent produire des erreurs distinctes : une chaîne vide ferait naître de fausses conclusions.

Ce contrat est un dessin d’interface ; l’exemple qui suit en est une petite implémentation exécutée localement. Dans le SDK Python actuel, les annotations et la docstring d’une fonction décorée avec @mcp.tool() alimentent son schéma et sa description. Le SDK documente MCPServer en v2, alors que le workbook utilise FastMCP : épinglez la version et les imports d’un exemple avant de le copier. (Premiers pas du SDK Python, nouveautés v2)

Un serveur et un test reproductibles

Copiez le bloc suivant dans server.py. Il n’utilise qu’une liste fictive en mémoire : aucune connexion, aucun C2, aucune cible. L’import MCPServer et ToolError correspond au SDK Python mcp==2.3.0 ; le filtrage, le curseur et le plafond sont dans notre code, pas fournis automatiquement par MCP. Une erreur attendue est levée avec ToolError pour que le client reçoive is_error=True. (Documentation des erreurs du SDK)

from typing import Literal

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

mcp = MCPServer("Session catalogue")
SESSIONS = [
    {"id": "s-101", "started_at": "2026-10-20T08:00:00Z", "status": "open", "summary": "Login review", "evidence_refs": ["log-11"]},
    {"id": "s-102", "started_at": "2026-10-20T09:00:00Z", "status": "closed", "summary": "Patch check", "evidence_refs": ["log-12"]},
    {"id": "s-103", "started_at": "2026-10-20T10:00:00Z", "status": "open", "summary": "API review", "evidence_refs": ["log-13"]},
]


@mcp.tool()
def list_sessions(
    status: Literal["open", "closed"] | None = None,
    cursor: str | None = None,
    limit: int = 2,
) -> dict:
    """List fictional sessions; return IDs and a cursor, never full logs."""
    if not 1 <= limit <= 20:
        raise ToolError("limit must be between 1 and 20")
    rows = [r for r in SESSIONS if status is None or r["status"] == status]
    ids = [r["id"] for r in rows]
    if cursor is not None and cursor not in ids:
        raise ToolError("unknown cursor")
    start = ids.index(cursor) + 1 if cursor is not None else 0
    page = rows[start:start + limit]
    return {
        "items": [{k: r[k] for k in ("id", "started_at", "status", "summary")} for r in page],
        "next_cursor": page[-1]["id"] if start + limit < len(rows) else None,
    }


@mcp.tool()
def get_session(session_id: str) -> dict:
    """Read one fictional session by opaque ID."""
    for row in SESSIONS:
        if row["id"] == session_id:
            return row
    raise ToolError("session not found")

Copiez ce second bloc dans test_server.py, dans le même répertoire. Il appelle le serveur en mémoire : aucun port n’est ouvert. La commande reproduit les assertions sur les deux pages, le détail et trois refus ; les lignes « Tool failed » sont les erreurs attendues des cas négatifs. (Tests en mémoire du SDK)

import anyio
import json

from mcp import Client
from server import mcp


async def main():
    async with Client(mcp, raise_exceptions=True) as client:
        first = await client.call_tool("list_sessions", {"status": "open", "limit": 1})
        first_data = json.loads(first.content[0].text)
        assert first_data["items"][0]["id"] == "s-101"
        cursor = first_data["next_cursor"]
        second = await client.call_tool("list_sessions", {"status": "open", "cursor": cursor, "limit": 1})
        second_data = json.loads(second.content[0].text)
        assert second_data["items"][0]["id"] == "s-103"
        assert second_data["next_cursor"] is None
        detail = await client.call_tool("get_session", {"session_id": "s-103"})
        assert json.loads(detail.content[0].text)["evidence_refs"] == ["log-13"]
        missing = await client.call_tool("get_session", {"session_id": "s-999"})
        assert missing.is_error is True
        bad_limit = await client.call_tool("list_sessions", {"limit": 21})
        assert bad_limit.is_error is True
        bad_cursor = await client.call_tool("list_sessions", {"cursor": "s-999"})
        assert bad_cursor.is_error is True
        print("PASS: pagination, detail, missing session, bad limit, bad cursor")


anyio.run(main)
uv run --no-project --with mcp==2.3.0 python test_server.py

Nous avons exécuté cette commande localement : elle s’est terminée avec PASS: pagination, detail, missing session, bad limit, bad cursor. Ce test prouve le comportement de ce petit exemple, pas son intérêt pour un agent C2 ni sa sécurité dans une intégration distante. Pour le rendre visible dans un host, le SDK fournit aussi uv run mcp dev server.py et son Inspector ; ce serait une autre étape à valider avec le host choisi. (Premiers pas du SDK)

Les cas limites font partie de la fonction

Une première implémentation devrait utiliser une source factice déterministe, puis tester un résultat vide, plusieurs pages, un identifiant absent, un délai dépassé et une réponse de plusieurs mégaoctets. Sans pagination, list_sessions peut saturer le contexte avec des données inutiles ; sans délai maximal, un appel bloqué rend l’assistant aveugle à la cause. Renvoyez un nombre borné d’éléments, un curseur et des identifiants qui permettent d’aller chercher le détail. Les conseils d’Anthropic sur la conception d’outils insistent précisément sur la sélection des outils utiles et des réponses adaptées au budget de contexte.

Il faut ensuite éprouver deux couches différentes. Le test du serveur vérifie que les entrées mal formées sont refusées et que les sorties respectent le contrat, sans modèle. Le SDK Python permet un client en mémoire pour ces tests et fournit un Inspector pour voir les outils exposés. Le test de l’assistant lui soumet des tâches réalistes : « trouve la session ouverte qui mentionne tel événement, puis donne sa référence ». On relit la transcription brute : choix d’outil, arguments, pagination, gestion de l’erreur et référence citée. Une belle réponse finale ne suffit pas si le modèle a lu la mauvaise session. (Guide de test du SDK)

L’autorisation ne naît pas du schéma

En local, stdio évite d’ouvrir un service réseau, mais le processus garde les droits de l’utilisateur qui le lance. Pour un serveur distant, l’authentification du client, les droits de l’API appelée et la séparation des identités deviennent des décisions explicites. Un outil « lecture seule » relié à un compte administrateur n’est pas magiquement limité par sa description. Le guide MCP documente notamment le relais de jetons, le confused deputy, la SSRF et la réduction des scopes. (Bonnes pratiques de sécurité MCP)

Dans une intégration offensive réelle, je commencerais par la lecture seule avec un compte réellement limité en aval. Une mutation demanderait une autorisation hors du modèle portant sur l’action exacte, la cible et ses effets. Le host peut présenter une confirmation, mais il faut vérifier ce que le serveur et l’API refusent eux-mêmes si cette confirmation manque. La spécification recommande une possibilité humaine de refuser un appel d’outil ; elle ne transforme pas cette recommandation en contrôle d’accès universel. (Spécification des outils MCP)

Avant de publier un serveur, vérifions que le contrat tient sur des entrées mauvaises, que le modèle choisit le bon outil et que les réponses restent courtes et vérifiables. Aucun chemin alternatif ne doit lui donner plus de droits que prévu.

#rootzero

Une question sur votre exposition ?

Écrivez-nous. Une personne de l’équipe technique vous répond, sources à l’appui.