Tester un serveur MCP avec des clients scriptés
Un client scripté est un petit programme qui envoie à un serveur une liste fixe de messages et vérifie chaque réponse. Il ne se fatigue jamais et ne saute aucune étape.
C’est utile, car un serveur se casse facilement sans le vouloir. Un seul caractère changé peut modifier une réponse, et personne ne le voit avant qu’un vrai client échoue.
À la fin de cette leçon
Remarque
Je peux tester un serveur avec des clients scriptés.
Mots clés
| Mot | Ce que ça veut dire | Exemple |
|---|---|---|
| Scénario | une liste de messages à envoyer, chacun avec la réponse attendue | |
| Attendu | la réponse que veut le test | |
| Reçu | la réponse que le serveur a vraiment envoyée | |
| Écart | le premier endroit où les deux ne correspondent pas | |
| Joker | un repère qui correspond à toute valeur | <tout> |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Un scénario associe chaque message à sa réponse
Un scénario est une liste d’étapes. Chaque étape a un message à envoyer et la réponse attendue. Une étape de notification n’attend aucune réponse.
Certaines valeurs changent, comme un numéro de version. Le test les marque d’un joker : un changement à cet endroit n’est pas un échec. Tout le reste doit correspondre exactement.
Exemple : un scénario pour un outil qui additionne
| Étape | Envoyé | Réponse attendue |
|---|---|---|
| 1 | initialize | La version demandée, tools annoncé, n’importe quelle version du serveur |
| 2 | La notification prêt | Aucune réponse |
| 3 | tools/list | Un outil nommé additionner |
| 4 | additionner 2 et 3 | Le texte 5, isError faux |
| 5 | additionner « 2 » et 3 | Une erreur d’outil, isError vrai |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Dire le premier écart, avec son endroit
Un test qui dit seulement « échec » fait perdre du temps. Un bon test dit où. Le chemin result.content[0].text désigne une valeur précise de la réponse.
Le vérificateur parcourt la réponse attendue dans un ordre fixe et s’arrête au premier écart. Il dit ce qu’il attendait et ce qu’il a reçu.
Une erreur courante
Attention
Beaucoup lancent un test seulement sur un serveur qui marche. Un test qui n’a jamais échoué n’a jamais prouvé qu’il attrape quoi que ce soit. Cassez le serveur exprès et vérifiez que le test échoue. Puis défaites la casse.
É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' 'function envoyer(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'const SCHEMA = { type: \"object\", properties: { a: { type: \"integer\" }, b: { type: \"integer\" } }, required: [\"a\", \"b\"] };' 'function mots(texte, mauvais) { return { content: [{ type: \"text\", text: texte }], isError: mauvais }; }' > serveur6.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 traiter(msg) {' ' if (msg.id === undefined) return;' ' const p = msg.params || {};' ' const args = p.arguments || {};' ' if (msg.method === \"initialize\") return envoyer({ id: msg.id, result: { protocolVersion: p.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: \"additionneur\", version: \"1.0\" } } });' >> serveur6.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 (msg.method === \"tools/list\") return envoyer({ id: msg.id, result: { tools: [{ name: \"additionner\", description: \"Additionner deux entiers\", inputSchema: SCHEMA }] } });' ' if (msg.method === \"tools/call\" && p.name === \"additionner\") {' ' if (Number.isInteger(args.a) && Number.isInteger(args.b)) return envoyer({ id: msg.id, result: mots(String(args.a + args.b), false) });' ' return envoyer({ id: msg.id, result: mots(\"a et b doivent être des entiers\", true) });' ' }' ' if (msg.method === \"tools/call\") return envoyer({ id: msg.id, error: { code: -32602, message: \"Outil inconnu : \" + p.name } });' ' envoyer({ id: msg.id, error: { code: -32601, message: \"Méthode inconnue : \" + msg.method } });' '}' 'require(\"readline\").createInterface({ input: process.stdin }).on(\"line\", function (ligne) { traiter(JSON.parse(ligne)); });' >> serveur6.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 vérificateur, 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\", [process.argv[2] || \"serveur6.js\"]);' 'const TOUT = \"<tout>\";' 'const un = function (id, method, params) { return { jsonrpc: \"2.0\", id: id, method: method, params: params }; };' 'const somme = function (id, args) { return un(id, \"tools/call\", { name: \"additionner\", arguments: args }); };' 'const ok = function (id, result) { return { jsonrpc: \"2.0\", id: id, result: result }; };' 'const texte = function (t, mauvais) { return { content: [{ type: \"text\", text: t }], isError: mauvais }; };' > verif6.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 vérificateur, partie 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const etapes = [' ' [un(1, \"initialize\", { protocolVersion: \"2025-03-26\" }), ok(1, { protocolVersion: \"2025-03-26\", capabilities: { tools: {} }, serverInfo: { name: \"additionneur\", version: TOUT } })],' ' [{ jsonrpc: \"2.0\", method: \"notifications/initialized\" }, null],' ' [un(2, \"tools/list\"), ok(2, { tools: [{ name: \"additionner\", description: TOUT, inputSchema: TOUT }] })],' ' [somme(3, { a: 2, b: 3 }), ok(3, texte(\"5\", false))],' ' [somme(4, { a: \"2\", b: 3 }), ok(4, texte(\"a et b doivent être des entiers\", true))],' ' [un(5, \"tools/call\", { name: \"rien\" }), { jsonrpc: \"2.0\", id: 5, error: { code: -32602, message: \"Outil inconnu : rien\" } }],' '];' >> verif6.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 vérificateur, partie 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function estObj(x) { return Boolean(x) && typeof x === \"object\"; }' 'function nom(chemin, k, liste) { return liste ? chemin + \"[\" + k + \"]\" : (chemin ? chemin + \".\" + k : k); }' 'function ecart(voulu, recu, chemin) {' ' if (voulu === TOUT) return null;' ' const deux = estObj(voulu) && estObj(recu) && Array.isArray(voulu) === Array.isArray(recu);' ' const dit = \" : attendu \" + JSON.stringify(voulu) + \", reçu \" + JSON.stringify(recu);' ' if (deux === false) return JSON.stringify(voulu) === JSON.stringify(recu) ? null : (chemin || \"(racine)\") + dit;' >> verif6.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 vérificateur, partie 4
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' const liste = Array.isArray(voulu);' ' if (liste && (voulu.length === recu.length) === false) return (chemin || \"(racine)\") + \" : attendu \" + voulu.length + \" éléments, reçu \" + recu.length;' ' const cles = Object.keys(voulu).sort();' ' for (const k of cles) {' ' if ((k in recu) === false) return nom(chemin, k, liste) + \" : absent\";' ' const d = ecart(voulu[k], recu[k], nom(chemin, k, liste));' ' if (d) return d;' ' }' ' const en_trop = Object.keys(recu).sort().filter(function (k) { return cles.indexOf(k) < 0; });' ' return en_trop.length > 0 ? nom(chemin, en_trop[0], liste) + \" : en trop\" : null;' '}' >> verif6.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 vérificateur, partie 5
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const lignes = [];' 'const attente = [];' 'require(\"readline\").createInterface({ input: serveur.stdout }).on(\"line\", function (l) {' ' if (attente.length > 0) attente.shift()(JSON.parse(l)); else lignes.push(JSON.parse(l));' '});' 'function suivante() { return lignes.length > 0 ? Promise.resolve(lignes.shift()) : new Promise(function (r) { attente.push(r); }); }' >> verif6.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 vérificateur, partie 6
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' let reussis = 0, total = 0, premier = null;' ' for (let i = 0; i < etapes.length; i++) {' ' serveur.stdin.write(JSON.stringify(etapes[i][0]) + \"\n\");' ' if (etapes[i][1] === null) continue;' ' total += 1;' ' const d = ecart(etapes[i][1], await suivante(), \"\");' ' console.log((d ? \"ÉCHEC étape \" + (i + 1) + \" à \" + d : \"RÉUSSI étape \" + (i + 1)));' ' if (d === null) reussis += 1; else if (premier === null) premier = d;' ' }' >> verif6.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 vérificateur, partie 7
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' serveur.stdin.end();' ' console.log(\"réussis \" + reussis + \" sur \" + total);' ' if (premier === null) return;' ' const q = \"Un test de mon serveur a échoué avec cet écart. En une phrase, quelle est la cause probable ? \" + premier;' ' const r = await demander([{ role: \"user\", content: q }], \"Le serveur a donné un autre texte que celui que le test attendait.\", 60);' ' console.log(\"le modèle dit : \" + r.texte);' '}' 'main();' >> verif6.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 vérificateur sur le bon serveur
docker exec -w /home/node/project agent node verif6.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.
Faire une copie avec une mauvaise modification
docker exec -w /home/node/project agent sh -c "sed 's/args.a + /args.a - /' serveur6.js > mauvais6.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 vérificateur sur la mauvaise copie
docker exec -w /home/node/project agent node verif6.js mauvais6.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.
Les deux Runs côte à côte
| Run | Étapes vérifiées | Résultat |
|---|---|---|
| Bon serveur | 1, 3, 4, 5 et 6 | réussis 5 sur 5 |
| Mauvaise copie | 1, 3, 4, 5 et 6 | réussis 4 sur 5 |
| Mauvaise copie, étape 4 | additionner 2 et 3 | result.content[0].text : attendu "5", reçu "-1" |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.