# Le backpressure MCP stdio peut-il bloquer votre agent ?

Un résultat d'outil MCP volumineux peut bloquer un processus agent même lorsque chaque ligne de l'implémentation du protocole est techniquement valide. Le problème vient du fait que l'on traite stdio comme un bus de messages infiniment rapide. Il s'agit en réalité d'un flux d'octets limité entre deux processus, et les deux directions peuvent se bloquer dès que l'un des côtés cesse de lire.

Cela compte davantage pour les outils d'agents que pour les programmes en ligne de commande ordinaires. Un outil peut produire un résultat de recherche gigantesque, un fichier encodé, une réponse API complète ou la transcription détaillée d'une commande. L'agent peut ensuite le tokenizer, le résumer, attendre une approbation ou simplement décider qu'il dispose déjà de suffisamment de contexte. Si l'hôte cesse de vider stdout du serveur pendant l'une de ces opérations, le serveur peut se bloquer au milieu de sa réponse. Une fois bloqué, il risque de ne plus lire une notification d'annulation ni une requête ultérieure sur stdin.

La solution ne tient pas à un seul réglage. Il faut un contrat de réponse qui maintient les résultats à une taille raisonnable, une boucle de transport qui vide stdout indépendamment du travail de l'agent et un chemin d'annulation qui atteint réellement le travail producteur. Testez ces trois éléments avec des charges volontairement hostiles.

## Un tube bloqué peut ressembler à un bug de l'agent

Un tube stdio bloqué produit des symptômes qui orientent souvent les équipes dans la mauvaise direction. L'agent semble figé après un appel d'outil. Le serveur est toujours en vie. L'utilisation du processeur peut rester faible. Un délai d'expiration se déclenche, mais une nouvelle tentative se bloque aussi. Quelqu'un accuse le runtime du modèle, le SDK MCP ou un verrou dans l'implémentation de l'outil.

Souvent, l'outil a déjà terminé son travail. Il est bloqué dans `write()` en essayant d'envoyer une réponse que l'hôte ne vide plus. La capacité d'un tube est limitée et dépend de la plateforme. La documentation des processus enfants de Node le dit clairement, comme le savent les programmeurs shell depuis des décennies : lorsqu'un sous-processus écrit davantage que ce qu'un tube peut contenir et que le parent ne capture pas cette sortie, le sous-processus se bloque jusqu'à ce que le tube accepte davantage d'octets.

Deux files distinctes interviennent dans cet incident, et les confondre mène à de mauvaises corrections.

- Le tube du système d'exploitation contient les octets stdout bruts entre le serveur MCP et son hôte.
- La file applicative de l'hôte contient les messages JSON-RPC analysés qui attendent le code de l'agent, l'interface, la journalisation ou la constitution du contexte.

Augmenter une file applicative ne sert à rien si personne ne lit le tube. Augmenter la taille du tube ou du tampon du flux peut retarder le blocage, mais cela donne au serveur davantage de place pour créer une réponse que l'agent n'aurait jamais dû recevoir. Le budget de résultat détermine ce qui doit entrer dans la conversation. La gestion du backpressure détermine ce qui se passe lorsqu'un côté est malgré tout plus lent. Ces deux mécanismes répondent à des problèmes différents.

Les recommandations officielles de débogage MCP fournissent aussi une limite de diagnostic simple : les serveurs stdio locaux doivent garder leurs journaux hors de stdout. Placez les diagnostics dans stderr. Si stdout contient une bannière, une trace d'erreur ou une ligne de progression qui ne fait pas partie des données du protocole, le découpage des messages est déjà corrompu avant même d'aborder le problème des gros résultats.

## Stdout doit rester lisible pendant la poursuite du travail

L'hôte doit respecter la règle la plus importante : connecter le lecteur stdout et le laisser fonctionner pendant toute la durée de vie du serveur. Ne le rattachez pas à une promesse qui attend que l'agent ait fini d'examiner un résultat d'outil. Ne le mettez pas en pause pendant une boîte de dialogue d'approbation. N'attendez pas un rendu, une écriture en base de données ou une requête au modèle avant d'accepter les octets suivants.

Utilisez une boucle de transport aux responsabilités étroites :

1. Lire stdout sous forme d'octets dès que le système d'exploitation les fournit.
2. Transmettre ces octets à l'analyseur de trames du protocole.
3. Rejeter les trames incorrectes ou trop volumineuses comme des erreurs de transport.
4. Transmettre les messages complets à un répartiteur limité, séparé du lecteur.
5. Continuer à vider le flux ou terminer volontairement le processus enfant lorsque le répartiteur ne peut plus accepter de travail.

Le mot important est « séparé ». Un lecteur qui appelle directement un traitement de message coûteux finira par devenir un lecteur qui cesse de lire. L'analyse JSON elle-même peut poser problème lorsqu'un message est énorme, mais l'erreur habituelle survient plus tôt : le lecteur attend un rappel qui effectue un travail sans rapport.

Pour chaque session serveur, un hôte devrait au minimum enregistrer les valeurs suivantes : octets reçus sur stdout, plus grande trame complète, erreurs d'analyse, temps passé à attendre une place dans le répartiteur, demandes d'annulation envoyées et sorties de processus. Ces chiffres mettent rapidement fin aux débats. Si le nombre d'octets stdout cesse d'augmenter au milieu d'un résultat alors que le serveur reste en vie, suspectez le côté écriture du serveur. Si les octets continuent d'arriver mais que la distribution des messages complets s'arrête, suspectez la file de l'hôte ou son consommateur.

Ne faites pas dépendre le contrôle du flux stdout de l'utilité d'une réponse d'outil pour le modèle. Le lecteur doit recevoir le message complet du protocole avant de pouvoir le supprimer, le signaler ou le router en toute sécurité. Un hôte qui décide au milieu d'une trame que le message est trop volumineux puis cesse de lire a créé lui-même le blocage.

## Une limite de résultat nécessite deux seuils

Définissez une limite de transport et une limite de contenu. Un simple nombre de caractères ne protège pas le processus, car l'échappement JSON, l'encodage base64 et la structure environnante de la réponse modifient le nombre d'octets écrits sur stdout.

La limite de transport est la taille maximale en octets d'un message JSON-RPC sérialisé complet. Appliquez-la dans l'analyseur de trames avant d'analyser un JSON arbitraire. Elle protège la mémoire et le temps d'analyse de l'hôte. La limite de contenu est la quantité maximale utile qu'un outil renvoie dans `content` ou `structuredContent`. Appliquez-la dans le gestionnaire de l'outil avant de sérialiser le résultat. Elle protège le contexte de l'agent et maintient la réponse pertinente.

Aucune de ces limites ne doit figurer uniquement dans la description de l'outil. Un modèle demandera parfois une recherche sans limite, une liste récursive ou un document complet. Le serveur doit traiter cette demande de façon prévisible.

Une réponse d'outil pratique indique ce qui a été omis et comment l'agent peut continuer. C'est préférable à la simple découpe silencieuse d'une chaîne, car une troncature silencieuse ressemble à une preuve complète.

```json
{
  "jsonrpc": "2.0",
  "id": 41,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Returned 50 of 4,382 matching records. Results are sorted by updated time. Use cursor \"eyJvZmZzZXQiOjUwfQ\" to continue, or add a narrower path or query."
      }
    ],
    "structuredContent": {
      "items": [
        {"path": "src/auth.ts", "line": 18, "summary": "reads token from environment"}
      ],
      "nextCursor": "eyJvZmZzZXQiOjUwfQ",
      "truncated": true,
      "totalEstimate": 4382
    }
  }
}
```

Le texte fournit au modèle une explication claire. Le contenu structuré fournit au client un jeton de continuation stable et un indicateur de troncature lisible par la machine. N'envoyez pas un total inventé si compter tous les enregistrements est coûteux ou impossible. Indiquez `truncated: true` et omettez le nombre. Une précision fictive fait perdre davantage de temps qu'un résultat honnêtement incomplet.

Évitez la recommandation courante qui consiste à « renvoyer simplement un chemin de fichier ». Cela ne fonctionne que si l'hôte et le serveur partagent un système de fichiers, si le chemin est autorisé, si l'agent peut le lire et si l'artefact est toujours présent. Dans les environnements distants ou isolés, c'est une promesse impossible à tenir. Une référence peut être utile, mais elle doit s'accompagner d'une opération de lecture ou d'export avec ses propres limites.

## Renvoyez des décisions, pas un flux brut

La plupart des réponses trop volumineuses viennent d'outils dont le modèle de sortie a été copié d'une ligne de commande. `grep -R`, `git diff`, une API cloud de liste et une requête de base de données sont adaptés à un humain devant un terminal. Ils ne deviennent pas de bonnes interfaces pour agents simplement parce qu'on les enveloppe dans du JSON.

Un agent a généralement besoin de suffisamment d'éléments pour choisir son action suivante. Fournissez-lui un ensemble limité de correspondances, les champs pertinents et un moyen d'affiner la demande. Conservez l'artefact complet pour une procédure explicite d'export ou de récupération, dans laquelle l'appelant choisit la pagination ou une plage limitée.

Pour une recherche dans un dépôt, renvoyez les chemins, les plages de lignes, de courts extraits et la requête utilisée. Ne renvoyez pas chaque ligne correspondante d'un monorepo. Pour un client HTTP, renvoyez le statut, certains en-têtes, un aperçu limité du corps et une référence de réponse si votre produit peut en conserver une de manière sûre. N'encodez pas en base64 un téléchargement arbitraire dans `content`. Pour SSH, renvoyez une fin limitée de stdout et stderr, ainsi que le statut de sortie. Une commande qui affiche un fichier généré gigantesque vous indique qu'elle a produit un fichier gigantesque. L'agent a rarement besoin de chaque octet dans son contexte immédiat.

Placez les limites près de la source de l'expansion. Un serveur qui appelle une API distante doit transmettre la pagination et les sélecteurs de champs à cette API. Un serveur qui exécute un processus doit limiter la capture du sous-processus tout en continuant à vider stdout et stderr. Un serveur qui recherche dans des fichiers doit s'arrêter après avoir atteint son budget de résultats, plutôt que de recueillir toutes les correspondances puis de rogner la chaîne finale.

Cette distinction compte, car tronquer après la collecte protège le transport MCP, mais pas la machine qui effectue le travail. Une commande récursive peut toujours consommer de la mémoire, du disque et du processeur avant que la couche de réponse n'abandonne sa sortie.

Sallyport est utile ici, car il garde les identifiants HTTP et SSH hors de portée de l'agent pendant que les actions passent par sa passerelle locale. Cette séparation ne rend pas sûr le renvoi d'un corps API ou d'une transcription shell sans limite. Les auteurs d'outils ont donc toujours besoin de budgets de sortie explicites à la frontière de l'action.

## Reproduisez le blocage avant d'affirmer qu'il est corrigé

Vous ne pouvez pas tester cet échec en appelant un outil et en vérifiant qu'un gros résultat finit par apparaître. Construisez un banc de test qui cesse volontairement de vider stdout, puis prouvez que le serveur entre dans l'état bloqué. Prouvez ensuite que l'hôte normal ne se comporte jamais ainsi.

Cette petite fixture Node écrit une réponse JSON-RPC valide avec une charge assez grande pour dépasser la capacité habituelle d'un tube lorsque le parent ignore stdout. Elle reçoit une ligne de requête sur stdin, puis écrit la réponse par morceaux. L'attente de `drain` fournit la preuve : elle indique quand le runtime a appliqué du backpressure à l'écriture stdout du serveur.

```js
// oversized-server.mjs
import readline from "node:readline";
import { once } from "node:events";

const rl = readline.createInterface({ input: process.stdin });

for await (const line of rl) {
  const request = JSON.parse(line);
  const text = "x".repeat(8 * 1024 * 1024);
  const response = JSON.stringify({
    jsonrpc: "2.0",
    id: request.id,
    result: { content: [{ type: "text", text }] }
  }) + "\n";

  for (let start = 0; start < response.length; start += 16 * 1024) {
    const chunk = response.slice(start, start + 16 * 1024);
    if (!process.stdout.write(chunk)) {
      process.stderr.write("stdout backpressure observed\n");
      await once(process.stdout, "drain");
    }
  }
}
```

Lancez-le ensuite avec stdout redirigé vers un tube et laissez volontairement `child.stdout` sans lecteur. Continuez à lire stderr afin de voir le marqueur de backpressure. Envoyez une requête et attendez brièvement. Le processus enfant doit rester en vie et ne doit pas finir son écriture. C'est le comportement attendu, pas un défaut de Node.

```js
// blocked-parent.mjs
import { spawn } from "node:child_process";

const child = spawn(process.execPath, ["oversized-server.mjs"], {
  stdio: ["pipe", "pipe", "pipe"]
});

child.stderr.setEncoding("utf8");
child.stderr.on("data", chunk => process.stderr.write(chunk));

child.stdin.write(JSON.stringify({
  jsonrpc: "2.0",
  id: 1,
  method: "tools/call",
  params: { name: "large", arguments: {} }
}) + "\n");

setTimeout(() => {
  console.error("child still running:", child.exitCode === null);
  child.kill("SIGTERM");
}, 1000);
```

Ne reproduisez pas ce modèle de parent en production. Son objectif est de rendre l'échec évident. Remplacez le consommateur stdout absent par votre véritable analyseur de trames et exécutez la même fixture. Le processus enfant doit terminer la réponse, ou le client doit la refuser conformément à une limite définie, mais il ne doit pas rester bloqué parce que le parent a ignoré stdout.

Exécutez le test avec des charges contenant des guillemets, des caractères multioctets et de longues chaînes sans espaces. Ces cas révèlent les analyseurs qui comptent les caractères JavaScript plutôt que les octets UTF-8, ou qui supposent que chaque bloc lu se termine à la frontière d'un message.

## Rejetez une trame gigantesque sans recréer le blocage

La vérification de la taille d'une trame comporte un piège : un client qui cesse de consommer la trame trop volumineuse bloquera le serveur tout aussi sûrement qu'un client qui n'a jamais attaché de lecteur. Le client a besoin d'une règle de récupération.

Si votre protocole de tramage fournit un marqueur de fin clair, continuez à lire jusqu'à ce marqueur en supprimant la trame concernée, puis signalez une violation du protocole et décidez si la session peut continuer. Si l'encodage du protocole ne permet pas de retrouver les limites de façon sûre après dépassement, terminez le processus serveur, fermez stdin et démarrez une nouvelle session. Cela peut sembler radical, mais essayer de deviner où se termine un message JSON corrompu est pire.

MCP stdio utilise des messages JSON-RPC sur un flux local d'octets. Votre implémentation doit traiter le découpage comme du code de transport, et non comme un simple appel pratique à `split("\n")` après avoir accumulé un texte sans limite. Gardez un compteur d'octets pendant l'accumulation d'un message candidat. À chaque bloc entrant, trouvez les limites des trames complètes et distribuez-les, ou échouez dès que le candidat dépasse votre maximum.

N'analysez pas un message gigantesque uniquement pour découvrir qu'il est gigantesque. `JSON.parse` a besoin d'une chaîne complète en mémoire et peut allouer davantage que la taille de son entrée. Toute l'utilité d'une limite de transport est d'empêcher ce travail.

Si vous contrôlez les deux extrémités et utilisez du JSON délimité par des retours à la ligne, exigez un objet JSON par ligne, rejetez les retours à la ligne littéraux intégrés dans les chaînes via l'encodage JSON normal et écrivez exactement un délimiteur après chaque objet sérialisé complet. Le serveur ne doit jamais écrire de diagnostics humains sur stdout. La documentation de débogage MCP indique aux serveurs locaux d'envoyer leurs journaux vers stderr, car stdout est réservé au protocole.

Un lecteur doit également limiter le nombre de messages complets en attente de traitement par l'application. Si cette file est pleine, ne mettez pas stdout en pause indéfiniment. Vous pouvez refuser de nouvelles requêtes, annuler le travail lorsque le protocole le permet ou terminer la session. L'action correcte en cas de surcharge dépend de l'hôte, mais laisser stdout sans lecture n'est jamais une valeur par défaut sûre.

## L'annulation ne fonctionne que si elle atteint le producteur

Un délai d'expiration dans l'interface de l'agent n'est pas une annulation. Il ne modifie que l'opinion de l'interface sur la requête. Le serveur peut encore interroger une base de données, télécharger une réponse, exécuter un processus et écrire des mégaoctets sur stdout.

MCP définit `notifications/cancelled` pour une requête précédemment envoyée dans la même direction. La notification contient l'identifiant de la requête d'origine et peut inclure une raison. Le schéma MCP indique que le récepteur doit arrêter le traitement associé, libérer les ressources et considérer le résultat comme inutilisé. Il précise aussi qu'une annulation peut entrer en concurrence avec la fin du traitement. Le client doit donc accepter une réponse tardive, et le serveur une annulation concernant une requête déjà terminée.

Pour un `tools/call`, le client envoie une notification semblable à celle-ci dans la même session stdio :

```json
{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": {
    "requestId": 41,
    "reason": "result exceeded the client budget"
  }
}
```

Le serveur a besoin d'une table reliant l'identifiant de la requête au travail actif. Chaque entrée devrait contenir un contrôleur d'annulation ou son équivalent, un état d'achèvement et tout processus enfant, requête HTTP ou curseur qu'elle possède. Lorsque la notification d'annulation arrive, interrompez le travail, cessez de produire du contenu, nettoyez l'entrée de la table et évitez d'envoyer une réponse normale si elle n'a pas encore commencé.

C'est ici que les équipes commettent une erreur subtile. Elles ajoutent un signal d'interruption au gestionnaire de l'outil, mais le gestionnaire attend un processus enfant dont la capture stdout ignore ce signal. Ou elles annulent une récupération HTTP tout en laissant une transformation sérialiser un gigantesque tableau en mémoire. L'annulation n'est réelle que lorsqu'elle atteint chaque producteur et chaque opération en attente.

Traitez le moment où le serveur commence à écrire une réponse comme un état distinct. Une annulation peut arriver après que des octets ont déjà été placés dans le tube. Vous ne pouvez pas les retirer. L'hôte doit continuer à vider suffisamment le flux pour préserver la santé de la session, puis ignorer la réponse tardive associée à l'identifiant annulé. Le serveur doit arrêter tout travail supplémentaire dès qu'il voit l'annulation, mais il ne peut pas garantir l'absence de derniers octets.

## Un test d'annulation nécessite une seconde requête

Un bon test d'annulation vérifie la récupération, pas seulement le déclenchement d'un minuteur. Démarrez un outil qui produit une sortie assez lentement pour que le client puisse l'annuler pendant son exécution. Envoyez l'annulation. Soumettez ensuite une petite requête sans rapport dans la même session MCP. Cette seconde requête doit se terminer rapidement.

La séquence de test suivante détecte les échecs importants :

1. Démarrez un `tools/call` dont le gestionnaire émet une réponse volumineuse par morceaux ou invoque un producteur volontairement lent.
2. Attendez que le banc de test observe assez d'octets stdout pour savoir que la réponse a commencé.
3. Envoyez `notifications/cancelled` pour cet identifiant de requête.
4. Vérifiez que le producteur se termine ou signale son chemin d'interruption dans le délai choisi.
5. Envoyez une petite requête avec un nouvel identifiant, par exemple un outil de santé ou un outil echo limité.

La dernière requête est le véritable test. Elle révèle si le serveur est toujours bloqué en écriture, si son lecteur stdin a été privé de temps, si une tâche annulée a conservé un verrou global ou si l'hôte a cessé de vider stdout après avoir décidé d'annuler.

Testez aussi l'annulation avant le début du travail, au milieu d'une opération distante, pendant l'exécution d'un sous-processus et après le début de la réponse finale. Ce sont des états différents. Une implémentation qui en gère un correctement peut échouer dans un autre.

N'affirmez pas que l'annulation empêche toujours une réponse. La spécification MCP autorise les courses. Vérifiez plutôt que l'hôte reste correct si une réponse arrive après l'annulation et que le travail du serveur s'arrête lorsque l'annulation arrive assez tôt pour être utile.

## La sortie des processus a besoin de son propre chemin de vidage

Les serveurs MCP invoquent souvent des outils en ligne de commande. Cela ajoute une seconde paire de tubes à l'intérieur du serveur : celui-ci doit consommer stdout et stderr du processus enfant pendant qu'il effectue le travail MCP. S'il ne lit un flux que jusqu'à une limite puis s'arrête, l'enfant peut se bloquer avant de se terminer. Le serveur MCP externe peut alors sembler ignorer l'annulation parce qu'il attend un enfant qui ne peut plus progresser.

Capturez un aperçu limité, mais continuez à vider le flux après avoir atteint cette limite. Marquez la sortie comme tronquée et supprimez les octets restants. Si la commande accepte sa propre limite de résultat, transmettez-la avant de la lancer. Par exemple, demandez à un outil de recherche un nombre maximal de correspondances, à une base de données une page limitée ou à une commande de journaux une fin de flux. Vider le flux après la limite est un filet de sécurité, pas la stratégie de résultat principale.

Gardez stderr séparé de stdout. Un outil peut écrire des diagnostics utiles dans stderr tout en renvoyant un statut de sortie normal. Limitez et videz les deux flux indépendamment. Ne fusionnez jamais une sortie arbitraire de processus avec stdout du serveur MCP. Le canal stdout externe n'a qu'une seule fonction : les messages MCP sérialisés.

La même règle s'applique aux commandes SSH. Une commande distante peut écrire jusqu'à bloquer son canal. Capturez des données limitées, continuez à consommer les flux distants jusqu'à la fin ou l'annulation et indiquez honnêtement dans le résumé ce que vous avez supprimé. Une transcription complète appartient à un flux d'artefact conçu pour cela, pas à un résultat d'outil ordinaire.

## Intégrez l'échec à vos critères de mise en production

Les limites de résultat et l'annulation sont faciles à retirer par accident. Une refactorisation peut remplacer un lecteur en flux par `readFile`, transformer un appel API paginé en appel sans limite ou déplacer l'analyse dans un rappel d'interface. Conservez la fixture de résultat volumineux dans la suite de tests.

Vos critères de mise en production doivent couvrir une réponse valide juste sous la limite de transport, une réponse juste au-dessus, un champ unique gigantesque, de nombreux petits blocs de contenu, du JSON incorrect, un stderr bruyant, un journal accidentel dans stdout et une annulation pendant la sortie. Exécutez ces tests avec le véritable chemin de création du processus, pas seulement avec un transport en mémoire. Les tests en mémoire ne peuvent pas montrer le backpressure des tubes.

Notez le comportement attendu en termes simples : le client vide stdout en continu ; il n'analyse jamais une trame au-dessus de la limite configurée ; il signale un échec de résultat limité ; l'annulation atteint le travail actif ; et une autre requête peut continuer après la récupération. Les ingénieurs pourront modifier les détails d'implémentation sans affaiblir ces garanties.

Si vous ne devez effectuer qu'un seul changement, faites celui-ci : séparez le lecteur stdout du traitement de l'agent, puis testez-le avec un serveur qui écrit bien davantage que ce qu'un outil raisonnable devrait renvoyer. Ce test transforme un vague signalement d'« agent bloqué » en un échec reproductible et mesurable, que vous pourrez empêcher de revenir dans la prochaine version.
