Comment un serveur MCP lit ses messages
Un serveur MCP est un programme qui lit des messages JSON sur son entrée et en écrit sur sa sortie, un message par ligne. Un serveur qui fait cela mal marche seul, puis échoue avec tous les vrais clients.
Le module 67 a montré le côté client et les sortes de message. Ici, vous écrivez la boucle de lecture d’un serveur.
À la fin de cette leçon
Remarque
Je peux lire et écrire les messages JSON qu’échangent un serveur et un client.
Mots clés
| Mot | Ce que ça veut dire | Exemple |
|---|---|---|
| Tampon | le texte gardé jusqu’à l’arrivée d’une ligne entière | |
| Trame | un message entier coupé dans le flux au saut de ligne | |
| stdout | le flux de sortie : seuls les messages y vont | |
| stderr | le flux d’erreur : les traces y vont | |
| Erreur d’analyse | la ligne n’était pas du JSON | -32700 |
| Requête invalide | du JSON, mais pas une requête | -32600 |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Un flux se coupe en lignes
L’entrée n’arrive pas en messages entiers. Elle arrive par bouts. Un bout peut contenir la moitié d’un message, un message, ou deux et demi.
Le serveur garde un tampon. Il y ajoute chaque bout, coupe chaque ligne complète et garde le reste. Un message ne contient jamais de saut de ligne brut : un saut de ligne en termine donc toujours un.
Exemple : trois bouts de texte
| Bout arrivé | Tampon ensuite | Messages prêts |
|---|---|---|
| {"id":1} {"id | {"id | 1 message : {"id":1} |
| ":2} | (vide) | 1 message : {"id":2} |
| {"i | {"i | Aucun pour l’instant |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Une mauvaise ligne reçoit quand même une réponse
Une ligne qui n’est pas du JSON reçoit une erreur d’analyse. Le serveur ne peut lire aucun id : l’id est donc null.
Une ligne qui est du JSON sans être une requête reçoit Requête invalide. Si un id se lit, on le recopie. Une requête sans id est une notification, et même pour une méthode inconnue elle ne reçoit rien.
Ce que le serveur renvoie
| Ligne | Réponse |
|---|---|
| pas du json | Erreur -32700, id null |
| {"jsonrpc":"2.0","id":5,"method":5} | Erreur -32600, id 5 |
| [1,2] | Erreur -32600, id null |
| {"jsonrpc":"2.0","method":"truc"} | Rien : c’est une notification |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Les réponses reviennent dans n’importe quel ordre
Une requête lente ne doit pas retenir une requête rapide. Le serveur répond à chacune quand sa réponse est prête.
Un client ne peut donc pas associer les réponses par leur place. Il les associe par l’id. C’est pourquoi un serveur recopie l’id tel quel, nombre ou texte, comme il l’a reçu.
Une erreur courante
Attention
Beaucoup de gens déboguent avec un affichage. Sur stdout, cette ligne n’est pas un message, et le client échoue dessus. Seuls les messages vont sur stdout. Écrivez les traces sur stderr : le client peut les montrer ou les ignorer.
É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' 'let tampon = \"\";' 'function envoyer(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'function echec(id, code, texte) { envoyer({ id: id, error: { code: code, message: texte } }); }' 'function journal(texte) { process.stderr.write(\"journal : \" + texte + \"\n\"); }' > serveur1.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(ligne) {' ' let msg;' ' try { msg = JSON.parse(ligne); } catch (erreur) { return echec(null, -32700, \"Erreur d’analyse\"); }' ' const vu = msg && (typeof msg.id === \"number\" || typeof msg.id === \"string\") ? msg.id : null;' ' const bon = msg && typeof msg === \"object\" && msg.jsonrpc === \"2.0\" && typeof msg.method === \"string\";' ' if (Boolean(bon) === false) return echec(vu, -32600, \"Requête invalide\");' ' journal(\"reçu \" + msg.method);' ' if (msg.id === undefined) return;' ' const ms = (msg.params || {}).ms || 0;' ' if (msg.method === \"ping\") return envoyer({ id: msg.id, result: {} });' ' if (msg.method === \"attendre\") return setTimeout(function () { envoyer({ id: msg.id, result: { attendu: ms } }); }, ms);' ' echec(msg.id, -32601, \"Méthode inconnue : \" + msg.method);' '}' >> serveur1.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' 'process.stdin.on(\"data\", function (morceau) {' ' tampon += morceau.toString();' ' let fin = tampon.indexOf(\"\n\");' ' while (fin >= 0) {' ' const ligne = tampon.slice(0, fin);' ' tampon = tampon.slice(fin + 1);' ' if (ligne.trim().length > 0) traiter(ligne);' ' fin = tampon.indexOf(\"\n\");' ' }' '});' 'journal(\"prêt\");' >> serveur1.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\", [\"serveur1.js\"]);' 'const m = function (obj) { return JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, obj)) + \"\n\"; };' 'const attendre = function (id, ms) { return m({ id: id, method: \"attendre\", params: { ms: ms } }); };' 'const ping = m({ id: 3, method: \"ping\" });' 'const mauvais = \"pas du json\n\" + m({ id: 5, method: 5 }) + m({ method: \"notifications/x\" });' 'const etapes = [[0, attendre(1, 80)], [10, attendre(2, 10)], [20, ping.slice(0, 12)], [40, ping.slice(12) + mauvais]];' > client1.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' 'function demarrer() {' ' etapes.forEach(function (e) {' ' setTimeout(function () { console.log(\"-> \" + e[1].trim().split(\"\n\").join(\" | \")); serveur.stdin.write(e[1]); }, e[0]);' ' });' ' setTimeout(function () { serveur.stdin.end(); }, 200);' '}' 'require(\"readline\").createInterface({ input: serveur.stderr }).on(\"line\", function (ligne) {' ' console.log(\"stderr \" + ligne);' ' if (ligne === \"journal : prêt\") demarrer();' '});' 'require(\"readline\").createInterface({ input: serveur.stdout }).on(\"line\", function (ligne) { console.log(\"<- \" + ligne); });' >> client1.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 q = \"En une phrase, pourquoi une réponse doit-elle porter l’id de sa requête ?\";' ' const r = await demander([{ role: \"user\", content: q }], \"Pour que le client sache à quelle requête chaque réponse appartient, car les réponses peuvent arriver dans n’importe quel ordre.\", 60);' ' console.log(\"le modèle dit : \" + r.texte);' '});' >> client1.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 client1.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 fait le client
Le client lance le serveur et attend sa ligne prêt sur stderr. Il envoie deux requêtes d’attente, la moitié d’un ping, puis la fin du ping suivie de trois autres lignes.
Un appel en direct termine le script : il demande une phrase à un modèle. Sans modèle, le Run affiche un Échantillon enregistré pour cette ligne.
Les réponses, dans l’ordre où elles arrivent
| Ligne affichée avec <- | Pourquoi elle arrive à ce moment |
|---|---|
| id 2, attendu 10 | La courte attente a fini la première |
| id 3, un résultat vide | Le ping était entier quand sa seconde moitié est arrivée |
| id null, erreur -32700 | La ligne « pas du json » |
| id 5, erreur -32600 | Une méthode qui est un nombre |
| id 1, attendu 80 | La longue attente a fini la dernière |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.