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 commeticketslaisse 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: trueet 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_dossiervaut 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.