Comment un outil MCP contrôle son entrée
Un outil MCP reçoit ses arguments d’un modèle. Un modèle se trompe parfois : un texte à la place d’un nombre, un nom inventé, un champ oublié. Un serveur qui fait confiance sans regarder agit sur de mauvaises données.
Ici, vous écrivez le contrôle qui a lieu avant que l’outil tourne.
À la fin de cette leçon
Remarque
Je peux proposer un outil dont l’entrée est contrôlée avant qu’il tourne.
Mots clés
| Mot | Ce que ça veut dire | Exemple |
|---|---|---|
| Schéma | la liste des règles que l’entrée d’un outil doit suivre | |
| Argument | une valeur donnée à l’outil, avec son nom | |
| Énumération | la liste des seules valeurs permises | croque, wrap, salade |
| Erreur d’outil | un résultat avec isError à vrai, que le modèle peut lire | |
| Erreur de protocole | une erreur du message lui-même, avec un code |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Un schéma liste les règles de chaque argument
Le schéma d’un outil dit quels arguments existent, quel type chacun a, et lesquels sont obligatoires. Il peut aussi borner une valeur : une liste de choix, un minimum, un maximum, une longueur.
Le serveur annonce ce schéma dans tools/list. Il le vérifie aussi à chaque appel : une annonce n’oblige personne.
Exemple : l’outil commander_repas
| Argument | Règle | Obligatoire |
|---|---|---|
| plat | texte : croque, wrap ou salade | oui |
| quantite | entier de 1 à 5 | oui |
| note | texte de 20 caractères au plus | non |
| tout autre nom | refusé | sans objet |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Un refus dit au modèle quoi corriger
Quand un argument est mauvais, le serveur ne casse pas la session. Il renvoie un résultat avec isError à vrai et une phrase qui liste chaque défaut. Le modèle lit cette phrase et refait l’appel.
Un outil qui n’existe pas est autre chose : c’est une erreur de protocole, avec le code -32602.
Une erreur courante
Attention
Beaucoup pensent que le modèle suit le schéma, donc que le contrôle est inutile. Un modèle peut se tromper, et n’importe quel client peut envoyer n’importe quoi. Contrôlez toujours côté serveur, avant d’agir.
Écrire l’assistant du modèle
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function demander(messages, echantillon, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" }, body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 80 }), signal: AbortSignal.timeout(30000) });' ' if (res.ok === false) throw new Error(\"statut \" + res.status);' ' const donnees = await res.json();' ' return { texte: donnees.choices[0].message.content, enregistre: false };' ' } catch (erreur) {' ' console.log(\"Échantillon enregistré : aucun modèle n’a répondu (\" + erreur.message + \").\");' ' return { texte: echantillon, enregistre: true };' ' }' '}' 'module.exports = { demander };' > model.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le serveur, partie 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const SCHEMA = { type: \"object\", properties: { plat: { type: \"string\", enum: [\"croque\", \"wrap\", \"salade\"] }, quantite: { type: \"integer\", minimum: 1, maximum: 5 }, note: { type: \"string\", maxLength: 20 } }, required: [\"plat\", \"quantite\"], additionalProperties: false };' 'const MOTS = { string: \"texte\", integer: \"entier\" };' 'function envoyer(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'function bon_type(r, v) { return r.type === \"string\" ? typeof v === \"string\" : Number.isInteger(v); }' > serveur3.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le serveur, partie 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function defauts(args) {' ' const sortie = [];' ' SCHEMA.required.forEach(function (n) { if ((n in args) === false) sortie.push(\"manque : \" + n); });' ' Object.keys(SCHEMA.properties).forEach(function (n) {' ' if ((n in args) === false) return;' ' const r = SCHEMA.properties[n], v = args[n];' >> serveur3.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le serveur, partie 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' if (bon_type(r, v) === false) return sortie.push(n + \" : \" + MOTS[r.type] + \" attendu\");' ' if (r.enum && r.enum.indexOf(v) < 0) sortie.push(n + \" : doit être l’un de \" + r.enum.join(\", \"));' ' if (typeof r.minimum === \"number\" && (v < r.minimum || v > r.maximum)) sortie.push(n + \" : doit être entre \" + r.minimum + \" et \" + r.maximum);' ' if (typeof r.maxLength === \"number\" && v.length > r.maxLength) sortie.push(n + \" : au plus \" + r.maxLength + \" caractères\");' ' });' ' Object.keys(args).sort().forEach(function (n) { if ((n in SCHEMA.properties) === false) sortie.push(\"en trop : \" + n); });' ' return sortie;' '}' >> serveur3.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le serveur, partie 4
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function mots(texte, mauvais) { return { content: [{ type: \"text\", text: texte }], isError: mauvais }; }' 'function traiter(msg) {' ' if (msg.id === undefined) return;' ' const p = msg.params || {};' ' if (msg.method === \"initialize\") return envoyer({ id: msg.id, result: { protocolVersion: p.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: \"cantine\", version: \"0.3\" } } });' ' if (msg.method === \"tools/list\") return envoyer({ id: msg.id, result: { tools: [{ name: \"commander_repas\", description: \"Commander un repas à la cantine\", inputSchema: SCHEMA }] } });' >> serveur3.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le serveur, partie 5
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' if (p.name === \"commander_repas\") {' ' const args = p.arguments || {};' ' const trouves = defauts(args);' ' if (trouves.length > 0) return envoyer({ id: msg.id, result: mots(\"Impossible de lancer commander_repas : \" + trouves.join(\"; \"), true) });' ' return envoyer({ id: msg.id, result: mots(\"Commandé : \" + args.quantite + \" \" + args.plat, false) });' ' }' ' envoyer({ id: msg.id, error: { code: -32602, message: \"Outil inconnu : \" + p.name } });' '}' 'require(\"readline\").createInterface({ input: process.stdin }).on(\"line\", function (ligne) { traiter(JSON.parse(ligne)); });' >> serveur3.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le client scripté, partie 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { demander } = require(\"./model\");' 'const serveur = require(\"child_process\").spawn(\"node\", [\"serveur3.js\"]);' 'const commande = function (id, args) { return { id: id, method: \"tools/call\", params: { name: \"commander_repas\", arguments: args } }; };' 'const scenario = [' ' { id: 1, method: \"initialize\", params: { protocolVersion: \"2025-03-26\" } },' ' { method: \"notifications/initialized\" },' ' commande(2, { plat: \"wrap\", quantite: 2 }),' ' commande(3, { plat: \"soupe\", quantite: 9, couleur: \"rouge\" }),' ' commande(4, {}),' ' commande(5, { plat: \"croque\", quantite: \"2\" }),' ' { id: 6, method: \"tools/call\", params: { name: \"effacer_menu\", arguments: {} } },' '];' > client3.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le client scripté, partie 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const vues = [];' 'require(\"readline\").createInterface({ input: serveur.stdout }).on(\"line\", function (ligne) {' ' const m = JSON.parse(ligne);' ' console.log(\"<- \" + (m.result && m.result.content ? m.result.content[0].text + \" (isError \" + m.result.isError + \")\" : ligne));' ' vues.push(m);' ' if (vues.length === 6) serveur.stdin.end();' '});' 'scenario.forEach(function (m) { serveur.stdin.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); });' >> client3.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le client scripté, partie 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'serveur.on(\"close\", async function () {' ' const montre = vues[2].result.content[0].text;' ' const q = \"Un outil a refusé un appel. En une phrase, que doit changer celui qui appelle ? \" + montre;' ' const r = await demander([{ role: \"user\", content: q }], \"Choisir croque, wrap ou salade, une quantité de 1 à 5, et ne mettre aucune couleur.\", 60);' ' console.log(\"le modèle dit : \" + r.texte);' '});' >> client3.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Lancer le client contre le serveur
docker exec -w /home/node/project agent node client3.jsAffichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Ce que le script appelle
Le script ouvre la session, puis fait cinq appels. Un bon, un avec trois défauts, un sans aucun argument, un avec un nombre écrit en texte et un pour un outil qui n’existe pas.
À la fin, un court appel demande à un modèle ce que l’appelant doit changer. Sans modèle, le Run affiche un Échantillon enregistré.
Ce que chaque appel reçoit
| Appel | Réponse |
|---|---|
| 2 wrap, quantité 2 | Commandé : 2 wrap (isError false) |
| 3 soupe, quantité 9, couleur | Trois défauts listés (isError true) |
| 4 aucun argument | Deux défauts : manque plat et manque quantite |
| 5 quantité « 2 » | quantite : entier attendu |
| 6 effacer_menu | Erreur de protocole -32602 |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.