Conception · MCP · TypeScript

Concevoir des outils MCP que le modèle utilise bien

Un outil est une interface pour un modèle : nom et description qui guident, paramètres contraints par Zod, sortie structurée, erreurs exploitables et annotations de risque.

Un outil est une interface pour un modèle

Un outil MCP ressemble à une fonction d’API, mais son appelant n’est pas un développeur qui lit la documentation : c’est un modèle qui ne voit que le nom, la description et le schéma. Il choisit l’outil, remplit les paramètres et interprète le résultat à partir de ces seules informations.

Concevoir un bon outil, c’est donc réduire l’ambiguïté : dire quand l’utiliser, contraindre ce qu’il accepte, renvoyer ce dont le modèle a besoin pour la suite, et expliquer l’échec quand il survient.

Nom et description

  • Un verbe et un objet : rechercher_tickets, ajouter_commentaire. Un nom vague comme tickets laisse le modèle deviner l’action.
  • Quand l’utiliser : « à utiliser avant de répondre à une question sur un dossier client » oriente le choix entre plusieurs outils proches.
  • Ce qu’il ne fait pas : « ne prévient pas le client » évite une promesse erronée à l’utilisateur.
  • Un exemple par paramètre non évident, dans son describe() : le format attendu se montre mieux qu’il ne se décrit.

Le champ title est destiné aux interfaces humaines ; le modèle s’appuie surtout sur description.

Des paramètres contraints

Zod sert à la fois de documentation et de garde-fou : le SDK refuse un appel non conforme avant d’atteindre le handler, et le client reçoit une erreur de validation que le modèle peut corriger.

const inputSchema = {
  recherche: z.string().min(3).max(100)
    .describe("Mots-clés cherchés dans le titre et la description, ex. : attestation"),
  statut: z.enum(["ouvert", "en_attente", "resolu"]).optional()
    .describe("Filtre facultatif ; absent = tous les statuts"),
  limite: z.number().int().min(1).max(50).default(10)
    .describe("Nombre maximal de tickets renvoyés"),
};
  • une énumération plutôt qu’une chaîne libre dès que les valeurs sont connues ;
  • des bornes (min, max) qui protègent le serveur et le contexte de l’agent ;
  • une valeur par défaut raisonnable, pour que l’appel le plus simple soit le bon ;
  • des paramètres plats : un objet imbriqué sur trois niveaux est plus souvent mal rempli.

Une sortie structurée

Un texte suffit à un modèle, mais un client qui enchaîne les appels ou affiche un tableau a besoin de données. Avec un outputSchema, l’outil annonce la forme de son résultat et renvoie un structuredContent que le SDK vérifie contre ce schéma.

const Ticket = z.object({
  id: z.string(),
  titre: z.string(),
  statut: z.enum(["ouvert", "en_attente", "resolu"]),
  priorite: z.enum(["basse", "normale", "haute"]),
});

server.registerTool(
  "rechercher_tickets",
  {
    title: "Rechercher des tickets",
    description:
      "Recherche des tickets du support Acme par mots-clés. À utiliser avant de répondre à une question " +
      "sur un dossier client. Renvoie au plus `limite` tickets, les plus récents d'abord.",
    inputSchema,
    outputSchema: { tickets: z.array(Ticket), total: z.number() },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  async ({ recherche, statut, limite }) => {
    const { tickets, total } = await ticketing.rechercher(recherche, statut, limite);
    const resultat = { tickets, total };
    return {
      // structuredContent pour le programme, texte pour les clients qui ne lisent que content.
      structuredContent: resultat,
      content: [{ type: "text", text: JSON.stringify(resultat) }],
    };
  }
);

Le résultat est aussi recopié en texte dans content : tous les clients ne lisent pas encore la sortie structurée. On renvoie l’essentiel, pas l’objet complet de l’outil de ticketing : chaque champ inutile consomme le contexte de l’agent, comme l’explique l’article sur la fenêtre de contexte.

Des erreurs exploitables

Deux catégories d’échec se traitent différemment :

  • l’erreur métier (ticket introuvable, statut incompatible) est un résultat : on renvoie isError: true et un message qui dit quoi faire ensuite. Le modèle le lit et ajuste son appel ;
  • l’erreur technique (service indisponible, bug) peut être levée comme exception ; le SDK la transforme en résultat d’erreur, mais le message doit rester générique pour ne rien divulguer des internes.
server.registerTool(
  "ajouter_commentaire",
  {
    title: "Ajouter un commentaire",
    description:
      "Ajoute un commentaire interne à un ticket existant. Ne prévient pas le client. " +
      "Vérifier l'identifiant avec rechercher_tickets avant l'appel.",
    inputSchema: {
      ticketId: z.string().regex(/^T-\d{4,}$/).describe("Identifiant du ticket, ex. : T-1003"),
      texte: z.string().min(1).max(2000),
    },
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
  },
  async ({ ticketId, texte }) => {
    const ticket = await ticketing.lire(ticketId);
    if (!ticket) {
      // Erreur métier : le modèle la lit et peut corriger son appel.
      return {
        isError: true,
        content: [{ type: "text", text: `Ticket ${ticketId} introuvable. Rechercher d'abord avec rechercher_tickets.` }],
      };
    }
    if (ticket.statut === "resolu") {
      return { isError: true, content: [{ type: "text", text: `Ticket ${ticketId} résolu : commentaire refusé.` }] };
    }
    await ticketing.commenter(ticketId, texte);
    return { content: [{ type: "text", text: `Commentaire ajouté au ticket ${ticketId}.` }] };
  }
);

Annoncer les risques

Les annotations décrivent le comportement de l’outil au client :

  • readOnlyHint : l’outil ne modifie rien ;
  • destructiveHint : une modification peut effacer ou écraser des données ;
  • idempotentHint : rappeler l’outil avec les mêmes arguments n’a pas d’effet supplémentaire ;
  • openWorldHint : l’outil touche un système extérieur ouvert (Web, messagerie).

Un client s’en sert pour demander une confirmation avant un outil d’écriture, ou en autoriser un en lecture sans interruption. Ce sont des indications, pas une sécurité : un client ne doit pas faire confiance aux annotations d’un serveur inconnu, et le serveur garde ses propres contrôles d’accès.

Combien d’outils ?

Chaque outil déclaré occupe le contexte du modèle et multiplie les risques de confusion. Quelques règles :

  • partir des tâches de l’utilisateur, pas des points d’accès de l’API : un outil resumer_dossier vaut mieux que quatre appels que le modèle doit enchaîner seul ;
  • séparer la lecture de l’écriture, pour qu’un client puisse n’autoriser que la première ;
  • éviter les outils génériques (executer_requete) quand quelques outils étroits couvrent le besoin : ils sont plus faciles à sécuriser.

Prochaine étape : les deux autres primitives du protocole, ressources et prompts.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.