Kann MCP-stdio-Backpressure deinen Agenten einfrieren?
MCP-stdio-Backpressure kann einen Agenten durch übergroße Tool-Ergebnisse blockieren. Pipe-Blockaden reproduzieren, Ergebnisse begrenzen, sicher leeren und die Wiederherstellung nach Abbrüchen testen.

Ein großes MCP-Tool-Ergebnis kann einen Agent-Prozess einfrieren, obwohl jede Zeile der Protokollimplementierung technisch korrekt ist. Der Fehler entsteht, wenn stdio wie ein unendlich schneller Nachrichtenbus behandelt wird. Tatsächlich handelt es sich um einen begrenzten Bytestrom zwischen zwei Prozessen. Beide Richtungen können blockieren, sobald eine Seite nicht mehr liest.
Das ist bei Agent-Tools wichtiger als bei gewöhnlichen Kommandozeilenprogrammen. Ein Tool kann ein riesiges Suchergebnis, eine codierte Datei, eine vollständige API-Antwort oder ein ausführliches Befehlsprotokoll erzeugen. Der Agent kann die Daten anschließend tokenisieren, zusammenfassen, auf eine Genehmigung warten oder einfach entscheiden, dass sein Kontext ausreicht. Wenn der Host während dieser Arbeit nicht weiter aus stdout liest, kann der Server mitten in der Antwort blockieren. Sobald das passiert, liest er möglicherweise keine Abbruchmeldung und keine spätere Anfrage von stdin mehr.
Die Lösung ist keine einzelne Einstellung. Du brauchst einen Antwortvertrag, der Ergebnisse klein hält, eine Transportschleife, die stdout unabhängig von der Agent-Arbeit weiter leert, und einen Abbruchpfad, der die laufende Erzeugung erreicht. Teste alle drei Teile mit absichtlich problematischen Daten.
Eine blockierte Pipe kann wie ein Agent-Fehler aussehen
Eine blockierte stdio-Pipe erzeugt Symptome, die Teams in die falsche Richtung schicken. Der Agent scheint nach einem Tool-Aufruf eingefroren zu sein. Der Serverprozess lebt weiter. Die CPU-Auslastung kann niedrig sein. Ein Timeout tritt ein, aber auch ein Wiederholungsversuch hängt. Jemand macht die Laufzeit des Modells, das MCP-SDK oder eine Sperre in der Tool-Implementierung verantwortlich.
Oft hat das Tool seine Arbeit bereits erledigt. Es steckt in write() fest und versucht, eine Antwort zu liefern, deren Ausgabe der Host nicht mehr liest. Die Pipe-Kapazität ist begrenzt und hängt von der Plattform ab. Die Dokumentation von Node zu Kindprozessen sagt genau das, was Shell-Programmierer seit Jahrzehnten wissen: Wenn ein Unterprozess mehr schreibt, als eine Pipe aufnehmen kann, und der Elternprozess die Ausgabe nicht abholt, blockiert der Unterprozess, bis die Pipe wieder Bytes annimmt.
Bei diesem Vorfall gibt es zwei verschiedene Warteschlangen. Wer sie verwechselt, baut die falsche Lösung.
- Die Betriebssystem-Pipe enthält rohe stdout-Bytes zwischen MCP-Server und Host.
- Die Anwendungswarteschlange des Hosts enthält geparste JSON-RPC-Nachrichten, die auf Agent-Code, UI-Code, Logging oder den Kontextaufbau warten.
Eine größere Anwendungswarteschlange hilft nicht, wenn niemand die Pipe liest. Ein größerer Pipe- oder Stream-Puffer kann die Blockade hinauszögern, gibt dem Server aber nur mehr Platz, eine Antwort zu erzeugen, die der Agent gar nicht hätte erhalten sollen. Das Ergebnisbudget entscheidet, was in die Unterhaltung gehört. Backpressure-Behandlung entscheidet, was passiert, wenn eine Seite trotzdem langsamer ist. Das sind unterschiedliche Probleme.
Auch die offizielle MCP-Anleitung zum Debugging liefert eine einfache Diagnosegrenze: Lokale stdio-Server müssen Logs von stdout fernhalten. Schreibe Diagnoseausgaben nach stderr. Wenn stdout ein Banner, einen Stacktrace oder eine Fortschrittszeile enthält, die keine Protokolldaten ist, liegt bereits ein Framing-Fehler vor, noch bevor das Problem mit dem großen Ergebnis beginnt.
stdout muss lesbar bleiben, während die Arbeit weiterläuft
Der Host trägt die wichtigste Verantwortung: Hänge den stdout-Leser ein und lasse ihn während der gesamten Lebensdauer des Serverprozesses laufen. Binde ihn nicht an ein Promise, das wartet, bis der Agent ein Tool-Ergebnis verarbeitet hat. Pausiere ihn nicht während eines Genehmigungsdialogs. Warte nicht auf einen Renderer, einen Datenbank-Schreibvorgang oder eine Modellanfrage, bevor du die nächsten Bytes annimmst.
Verwende eine Transportschleife mit klar abgegrenzten Aufgaben:
- Lies stdout als Bytes, sobald das Betriebssystem sie liefert.
- Übergib diese Bytes an den Protokoll-Framer.
- Weise fehlerhafte oder zu große Frames als Transportfehler zurück.
- Übergib vollständige Nachrichten an einen begrenzten Dispatcher, der vom Leser getrennt ist.
- Leere den Stream weiter oder beende das Kind bewusst, wenn der Dispatcher keine weitere Arbeit annehmen kann.
Das entscheidende Wort ist «getrennt». Ein Leser, der die aufwendige Nachrichtenverarbeitung direkt aufruft, wird irgendwann zu einem Leser, der nicht mehr liest. Schon das Parsen von JSON kann bei einer riesigen Nachricht problematisch sein. Der häufigere Fehler passiert aber früher: Der Leser wartet auf einen Callback, der andere Arbeit erledigt.
Ein Host sollte pro Serversitzung mindestens folgende Werte erfassen: empfangene stdout-Bytes, den größten vollständigen Frame, Parsing-Fehler, die Wartezeit auf freie Dispatch-Kapazität, gesendete Abbruchanfragen und Prozessbeendigungen. Diese Zahlen beenden Diskussionen schnell. Wenn die Zahl der stdout-Bytes mitten in einem Ergebnis nicht mehr steigt, der Server aber weiterlebt, liegt der Verdacht auf der Schreibseite des Servers. Wenn weiter Bytes eintreffen, die Zustellung vollständiger Nachrichten aber stoppt, liegt das Problem wahrscheinlich in der Host-Warteschlange oder ihrem Verbraucher.
Mache den stdout-Datenfluss nicht davon abhängig, ob ein Tool-Ergebnis für das Modell nützlich ist. Der Leser muss die vollständige Protokollnachricht empfangen, bevor er sie sicher verwerfen, melden oder weiterleiten kann. Ein Host, der mitten in einem Frame entscheidet, die Nachricht sei zu groß, und dann nicht mehr liest, hat den Deadlock selbst erzeugt.
Ein Ergebnislimit braucht zwei Grenzen
Setze ein Wire-Limit und ein Inhaltslimit. Eine reine Zeichenanzahl schützt den Prozess nicht, weil JSON-Escaping, Base64-Codierung und die umgebende Antwortstruktur die Byte-Anzahl auf stdout verändern.
Das Wire-Limit ist die maximale Byte-Größe einer vollständig serialisierten JSON-RPC-Nachricht. Setze es im Framer durch, bevor beliebiges JSON geparst wird. Es schützt Speicher und Parserzeit im Host. Das Inhaltslimit ist die maximal sinnvolle Datenmenge, die ein Tool in content oder structuredContent zurückgibt. Setze es im Tool-Handler vor der Serialisierung des Ergebnisses durch. So schützt du den Agent-Kontext und hältst die Antwort sinnvoll.
Keines der beiden Limits gehört allein in eine Tool-Beschreibung. Ein Modell wird gelegentlich eine unbegrenzte Suche, eine rekursive Auflistung oder ein vollständiges Dokument anfordern. Der Server muss damit vorhersehbar umgehen.
Eine praktische Tool-Antwort erklärt, was ausgelassen wurde und wie der Agent fortfahren kann. Das ist besser, als eine Zeichenkette still abzuschneiden, weil eine stille Kürzung wie vollständige Evidenz aussieht.
{
"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
}
}
}
Der Text gibt dem Modell eine verständliche Erklärung. Die strukturierten Daten liefern dem Client ein stabiles Fortsetzungs-Token und ein maschinenlesbares Kürzungssignal. Erfinde keine Gesamtzahl, wenn das Zählen aller Datensätze teuer oder unmöglich ist. Setze truncated: true und lass die Anzahl weg. Eine ehrliche unvollständige Antwort ist hilfreicher als eine vorgetäuschte Genauigkeit.
Vermeide die verbreitete Empfehlung, «einfach einen Dateipfad zurückzugeben». Das funktioniert nur, wenn Host und Server dasselbe Dateisystem verwenden, der Pfad autorisiert ist, der Agent ihn lesen kann und das Artefakt noch vorhanden ist. In entfernten oder abgeschotteten Umgebungen ist das ein gebrochenes Versprechen. Eine Referenz kann sinnvoll sein, braucht aber eine passende Lese- oder Exportaktion mit eigenen Grenzen.
Gib Entscheidungen zurück, nicht das gesamte Rohmaterial
Die meisten übergroßen Antworten stammen von Tools, deren Ausgabemodell von der Kommandozeile übernommen wurde. grep -R, git diff, eine Cloud-Listen-API und eine Datenbankabfrage sind für Menschen am Terminal sinnvoll. Zu guten Agent-Schnittstellen werden sie nicht allein dadurch, dass du sie in JSON verpackst.
Ein Agent braucht normalerweise genug Belege, um seine nächste Aktion auszuwählen. Gib eine begrenzte Trefferliste, die relevanten Felder und eine Möglichkeit zurück, die Anfrage einzugrenzen. Bewahre das vollständige Artefakt für einen ausdrücklichen Export- oder Abrufpfad auf, bei dem der Aufrufer Paging oder einen begrenzten Bereich bewusst anfordert.
Gib bei der Repository-Suche Dateipfade, Zeilenbereiche, kurze Ausschnitte und die verwendete Suchanfrage zurück. Liefere nicht jede passende Zeile aus einem Monorepo. Gib bei einem HTTP-Client den Status, ausgewählte Header, eine begrenzte Vorschau des Bodys und, falls dein Produkt sicher einen solchen behalten kann, einen Antwort-Handle zurück. Wandle keinen beliebigen Download in Base64 um und lege ihn in content. Gib bei SSH ein begrenztes Ende von stdout und stderr sowie den Exit-Status zurück. Ein Befehl, der eine riesige generierte Datei ausgibt, hat dir bereits mitgeteilt, dass er eine riesige generierte Datei erzeugt hat. Der Agent braucht selten jedes Byte in seinem unmittelbaren Kontext.
Setze Grenzen möglichst nahe an der Stelle, an der die Datenmenge wächst. Ein Server, der eine entfernte API aufruft, sollte Pagination und Feldauswahl an die entfernte API weitergeben. Ein Server, der einen Prozess startet, sollte die Aufnahme der Unterprozessausgabe begrenzen und gleichzeitig stdout und stderr weiter leeren. Ein Server, der Dateien durchsucht, sollte nach Erreichen seines Ergebnisbudgets stoppen, statt alle Treffer zu sammeln und erst am Ende die Zeichenkette zu kürzen.
Dieser Unterschied ist wichtig, weil eine Kürzung nach dem Sammeln zwar das MCP-Datenvolumen schützt, aber nicht die Maschine, die die Arbeit erledigt. Ein rekursiver Befehl kann weiterhin Speicherplatz, Arbeitsspeicher und CPU verbrauchen, bevor die Antwortschicht seine Ausgabe verwirft.
Sallyport ist hier nützlich, weil es HTTP- und SSH-Zugangsdaten vom Agenten fernhält, während Aktionen über sein lokales Gateway laufen. Diese Grenze macht einen unbegrenzten API-Body oder ein vollständiges Shell-Protokoll nicht automatisch sicher. Tool-Autoren brauchen daher weiterhin ausdrücklich begrenzte Ausgaben an der Aktionsgrenze.
Reproduziere die Blockade, bevor du sie als behoben meldest
Du kannst diesen Fehler nicht testen, indem du ein Tool aufrufst und prüfst, ob ein großes Ergebnis irgendwann erscheint. Baue einen Testaufbau, der das Lesen von stdout absichtlich stoppt, und beweise, dass der Server den blockierten Zustand erreicht. Beweise anschließend, dass sich der normale Host niemals so verhält.
Diese kleine Node-Fixture schreibt eine gültige JSON-RPC-Antwort mit einer Nutzlast, die groß genug ist, um die gewöhnliche Pipe-Kapazität zu überschreiten, wenn der Elternprozess stdout ignoriert. Sie empfängt eine Anfragezeile von stdin und schreibt die Antwort dann in Teilen. Das Warten auf drain ist der Beleg: Es zeigt, wann die Laufzeit Backpressure auf den stdout-Schreiber des Servers angewendet hat.
// 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");
}
}
}
Starte den Prozess nun mit einer Pipe für stdout und lasse child.stdout absichtlich ungelesen. Lies stderr weiter, damit du die Backpressure-Markierung sehen kannst. Sende eine Anfrage und warte kurz. Das Kind sollte weiterlaufen und das Schreiben nicht abschließen. Das ist erwartetes Verhalten und kein Node-Fehler.
// 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);
Übernimm dieses Elternprozess-Muster nicht in die Produktion. Es soll den Fehler sichtbar machen. Ersetze den fehlenden stdout-Verbraucher durch deinen echten Framer und führe dieselbe Fixture aus. Das Kind sollte die Antwort abschließen, oder der Client sollte sie unter einem festgelegten Limit zurückweisen. Es darf aber nicht festhängen, weil der Elternprozess stdout ignoriert.
Führe den Test mit Nutzdaten aus, die Anführungszeichen, Mehrbytezeichen und lange Zeichenketten ohne Trennstellen enthalten. Damit findest du Framer, die JavaScript-Zeichen statt UTF-8-Bytes zählen oder annehmen, dass jeder Leseabschnitt an einer Nachrichtengrenze endet.
Einen riesigen Frame zurückweisen, ohne die Blockade neu zu erzeugen
Eine Größenprüfung für Frames hat eine Falle: Ein Client, der nach Erkennen des zu großen Frames nicht weiterliest, blockiert den Server genauso sicher wie ein Client ohne Leser. Der Client braucht eine Wiederherstellungsregel.
Wenn dein Framing-Protokoll ein eindeutiges Endemerkmal liefert, lies bis zu diesem Merkmal weiter, während du den fehlerhaften Frame verwirfst. Melde anschließend einen Protokollverstoß und entscheide, ob die Sitzung fortgesetzt werden kann. Wenn die Codierung des Protokolls nach Überschreiten des Limits keine sichere Wiederherstellung des Framings erlaubt, beende den Serverprozess, schließe stdin und starte eine neue Sitzung. Das klingt streng, ist aber besser, als das Ende einer beschädigten JSON-Nachricht zu erraten.
MCP stdio verwendet JSON-RPC-Nachrichten über einen lokalen Bytestrom. Behandle Framing als Transportcode und nicht als bequemen Aufruf von split("\n"), nachdem unbegrenzt Text gesammelt wurde. Halte beim Sammeln einer möglichen Nachricht einen Bytezähler. Finde in jedem eingehenden Datenblock entweder vollständige Frame-Grenzen und verarbeite vollständige Frames, oder brich ab, sobald der Kandidat sein Maximum überschreitet.
Parse keine riesige Nachricht nur, um festzustellen, dass sie riesig ist. JSON.parse braucht eine vollständige Zeichenkette im Speicher und kann mehr Speicher als die Eingabegröße reservieren. Genau diese Arbeit soll ein Wire-Limit verhindern.
Wenn du beide Seiten kontrollierst und zeilengetrenntes JSON verwendest, verlange ein JSON-Objekt pro Zeile, lehne durch normale JSON-Codierung erzeugte echte Zeilenumbrüche innerhalb von Zeichenketten ab und schreibe nach jedem vollständig serialisierten Objekt genau ein Trennzeichen. Der Server darf niemals menschlich lesbare Diagnoseausgaben nach stdout schreiben. Die MCP-Dokumentation zum Debugging weist lokale Server an, Logs nach stderr zu senden, weil stdout dem Protokoll gehört.
Ein Leser sollte außerdem die Zahl vollständiger Nachrichten begrenzen, die auf die Verarbeitung durch die Anwendung warten. Wenn diese Warteschlange voll ist, darf stdout nicht dauerhaft pausiert werden. Du kannst neue Anfragen zurückweisen, unterstützte Arbeit abbrechen oder die Sitzung beenden. Welche Überlastungsreaktion richtig ist, hängt vom Host ab. Ein dauerhaft ungelesenes stdout ist aber niemals eine sichere Standardeinstellung.
Ein Abbruch funktioniert nur, wenn er die Erzeugung erreicht
Ein Timeout in der Agent-Oberfläche ist kein Abbruch. Es ändert nur die Ansicht der Oberfläche auf die Anfrage. Der Server kann weiterhin eine Datenbank abfragen, eine Antwort herunterladen, einen Prozess ausführen und Megabytes nach stdout schreiben.
MCP definiert notifications/cancelled für eine zuvor gesendete Anfrage in derselben Richtung. Die Meldung enthält die ursprüngliche Anfrage-ID und kann einen Grund enthalten. Das MCP-Schema sagt, dass der Empfänger die zugehörige Verarbeitung stoppen, Ressourcen freigeben und das Ergebnis als ungenutzt behandeln soll. Es weist auch darauf hin, dass ein Abbruch mit dem Abschluss konkurrieren kann. Deshalb muss ein Client eine verspätete Antwort tolerieren, und ein Server muss einen Abbruch tolerieren, der nach dem Abschluss einer Anfrage eintrifft.
Für einen tools/call sendet der Client beispielsweise folgende Meldung über dieselbe stdio-Sitzung:
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {
"requestId": 41,
"reason": "result exceeded the client budget"
}
}
Der Server braucht eine Zuordnung von Anfrage-ID zu aktiver Arbeit. Jeder Eintrag sollte einen Abort-Controller oder ein sprachliches Äquivalent, einen Abschlussstatus und jeden zugehörigen Unterprozess, HTTP-Aufruf oder Cursor enthalten. Wenn die Abbruchmeldung eintrifft, brich die Arbeit ab, stoppe die Erzeugung von Ergebnisinhalten, räume den Eintrag auf und sende keine normale Antwort, sofern die Antwort noch nicht begonnen hat.
Hier passiert ein subtiler Fehler. Teams fügen dem Tool-Handler ein Abbruchsignal hinzu, aber der Handler wartet auf einen Unterprozess, dessen stdout-Aufnahme dieses Signal ignoriert. Oder sie brechen einen HTTP-Fetch ab, lassen aber eine Transformation weiter ein riesiges Array im Speicher serialisieren. Ein Abbruch ist erst dann echt, wenn er jede erzeugende und jede wartende Operation erreicht.
Behandle den Zeitpunkt, an dem der Server mit dem Schreiben der Antwort beginnt, als eigenen Zustand. Ein Abbruch kann eintreffen, nachdem bereits Bytes in der Pipe liegen. Diese Bytes lassen sich nicht zurückholen. Der Host sollte weiter genug lesen, um die Sitzung stabil zu halten, und die verspätete Antwort zur abgebrochenen ID anschließend ignorieren. Der Server sollte zusätzliche Arbeit stoppen, sobald er den Abbruch erkennt. Er kann aber nicht versprechen, dass keine verspäteten Bytes mehr existieren.
Ein Abbruchtest braucht eine zweite Anfrage
Ein guter Abbruchtest beweist die Wiederherstellung, nicht nur das Auslösen eines Timers. Starte ein Tool, das langsam genug Ausgaben erzeugt, damit der Client es während der Verarbeitung abbrechen kann. Sende den Abbruch. Übermittle danach eine kleine, unabhängige Anfrage über dieselbe MCP-Sitzung. Diese zweite Anfrage muss zügig abgeschlossen werden.
Die folgende Testsequenz findet die wichtigen Fehler:
- Starte einen
tools/call, dessen Handler eine große Antwort in Teilen ausgibt oder einen absichtlich langsamen Erzeuger aufruft. - Warte, bis der Testaufbau genügend stdout-Bytes beobachtet hat, um zu wissen, dass die Antwort begonnen hat.
- Sende
notifications/cancelledfür diese Anfrage-ID. - Prüfe, dass der Erzeuger innerhalb der festgelegten Frist beendet wird oder seinen Abbruchpfad meldet.
- Sende eine kleine Anfrage mit einer neuen ID, etwa an ein Health-Tool oder ein begrenztes Echo-Tool.
Die letzte Anfrage ist der eigentliche Test. Sie zeigt, ob der Server noch beim Schreiben blockiert, ob sein stdin-Leser nicht mehr drankommt, ob eine abgebrochene Aufgabe eine globale Sperre hält oder ob der Host nach der Abbruchentscheidung aufgehört hat, stdout zu leeren.
Teste den Abbruch außerdem vor Beginn der Arbeit, mitten in einer entfernten I/O-Operation, während ein Unterprozess läuft und nachdem die endgültige Antwort begonnen hat. Das sind verschiedene Zustände. Eine Implementierung, die einen davon sauber behandelt, kann bei einem anderen scheitern.
Behaupte nicht, dass ein Abbruch immer eine Antwort verhindert. Die MCP-Spezifikation erlaubt Wettläufe. Prüfe stattdessen, dass der Host bei einer Antwort nach dem Abbruch korrekt bleibt und dass die Serverarbeit stoppt, wenn der Abbruch rechtzeitig eintrifft.
Prozessausgabe braucht einen eigenen Drain-Pfad
MCP-Server rufen häufig Kommandozeilenprogramme auf. Dadurch entsteht im Server ein zweites Pipe-Paar: Der Server muss stdout und stderr des Kindprozesses lesen, während er MCP-Arbeit ausführt. Wenn er einen Kind-Stream nur bis zu einem Limit liest und dann stoppt, kann das Kind blockieren, bevor es beendet wird. Der äußere MCP-Server kann dann scheinbar einen Abbruch ignorieren, weil er auf ein Kind wartet, das nicht weiterkommt.
Sammle eine begrenzte Vorschau, leere den Stream aber auch nach Erreichen des Vorschau-Limits weiter. Markiere die Ausgabe als gekürzt und verwirf die restlichen Bytes. Wenn der Befehl selbst ein Ergebnislimit unterstützt, übergib es vor dem Start. Bitte beispielsweise ein Suchprogramm um eine maximale Trefferzahl, eine Datenbank um eine begrenzte Seite oder einen Log-Befehl um die letzten Einträge. Das Leeren nach dem Limit ist die Sicherheitsreserve, nicht die primäre Strategie für das Ergebnis.
Halte stderr von stdout getrennt. Ein Tool kann nützliche Diagnoseausgaben nach stderr schreiben und trotzdem mit einem normalen Exit-Status enden. Begrenze und leere beide Streams unabhängig voneinander. Vermische niemals beliebige Prozessausgaben mit stdout des MCP-Servers. Der äußere stdout-Kanal hat genau eine Aufgabe: serialisierte MCP-Nachrichten zu übertragen.
Dasselbe gilt für SSH-Befehle. Ein entfernter Befehl kann so lange ausgeben, bis sein Kanal blockiert. Sammle begrenzte Daten, lies die entfernten Streams bis zum Abschluss oder Abbruch weiter und beschreibe in der zurückgegebenen Zusammenfassung ehrlich, was verworfen wurde. Ein vollständiges Protokoll gehört in einen eigens dafür vorgesehenen Artefaktfluss und nicht in ein beiläufiges Tool-Ergebnis.
Nimm den Fehler in deine Release-Gates auf
Ergebnislimits und Abbruchlogik lassen sich leicht versehentlich entfernen. Ein Refactoring kann einen Streaming-Leser durch readFile ersetzen, einen paginierten API-Aufruf unbegrenzt machen oder das Parsen in einen UI-Callback verschieben. Behalte die Fixture für übergroße Ergebnisse in der Testsuite.
Dein Release-Gate sollte eine gültige Antwort knapp unter dem Wire-Limit, eine knapp darüber, ein riesiges einzelnes Feld, viele kleine Inhaltsblöcke, fehlerhaftes JSON, lautes stderr, ein versehentliches stdout-Log und einen Abbruch während der Ausgabe abdecken. Führe die Tests über den echten Prozessstart aus, nicht nur über einen Transport im Speicher. In-Memory-Tests zeigen keine Pipe-Backpressure.
Halte das erwartete Verhalten in einfacher Sprache fest: Der Client leert stdout kontinuierlich, parst niemals oberhalb seines konfigurierten Frame-Limits, meldet einen Fehler wegen eines begrenzten Ergebnisses, erreicht aktive Arbeit mit dem Abbruch und kann nach der Wiederherstellung eine weitere Anfrage ausführen. So können Entwickler Implementierungsdetails ändern, ohne diese Garantien zu schwächen.
Wenn du nur eine Änderung vornimmst, dann diese: Trenne den stdout-Leser von der Agent-Verarbeitung und teste ihn anschließend mit einem Server, der sehr viel mehr schreibt, als ein sinnvolles Tool zurückgeben sollte. Dieser Test macht aus einem vagen Bericht «Der Agent ist eingefroren» einen Fehler, den du reproduzieren, messen und aus dem nächsten Release heraushalten kannst.
FAQ
Warum friert ein MCP-Server nach einem großen Tool-Ergebnis ein?
Das passiert, wenn der Server schneller in stdout schreibt, als der Host die Ausgabe ausliest, oder wenn der Host beim Parsen, Puffern oder Weiterleiten eines Ergebnisses blockiert. Der Server wartet dann auf freien Platz in der Pipe und liest möglicherweise auch stdin nicht mehr. Eine spätere Anfrage, einschließlich einer Abbruchmeldung, kann hinter dieser Blockade hängen.
Wie groß darf ein MCP-Tool-Ergebnis höchstens sein?
In MCP gibt es keine einheitliche sichere Größe, da die sinnvolle Grenze vom Client, dem Kontextbudget des Modells, der Ergebnisdarstellung und der ausgeführten Arbeit abhängt. Lege ein Byte-Limit für die gesamte serialisierte JSON-RPC-Antwort und ein kleineres inhaltliches Limit für Tool-Inhalte fest. Mache die Kürzung anschließend eindeutig und gib dem Aufrufer einen Cursor, Pfad, Suchbegriff oder ein Folgetool, mit dem er den Rest abrufen kann.
Behebt ein größerer stdout-Puffer MCP-Backpressure?
Nein. Ein größerer Speicherpuffer verzögert den Fehler und kann aus blockierter Ausgabe einen Speicheranstieg machen. Der Host muss die Pipe kontinuierlich leeren, während der Server von vornherein keine absurd großen Antwortobjekte erzeugen sollte.
Wie funktioniert das Abbrechen eines MCP-Tools über stdio?
Die MCP-Abbruchmeldung heißt notifications/cancelled und enthält die ursprüngliche Anfrage-ID sowie optional einen Grund. Sie ist nicht absolut und kann mit dem Abschluss konkurrieren. Deshalb muss der Server sie mit einem echten Abbruchsignal verbinden, während der Client auch eine bereits geschriebene Antwort tolerieren muss.
Sollte ein MCP-Host stdout lesen, während der Agent beschäftigt ist?
Lies stdout kontinuierlich in einen Framing-Parser ein, setze während des Eintreffens der Bytes eine maximale Frame-Größe durch und leite vollständige Nachrichten an eine separate, begrenzte Arbeitswarteschlange weiter. Halte stdout niemals nur deshalb an, weil der Agent gerade ein früheres Tool-Ergebnis verarbeitet. Wenn die Warteschlange voll ist, braucht es eine festgelegte Überlastungsreaktion, statt den Transport ungelesen zu lassen.
Darf ich Debug-Ausgaben in einem MCP-Server nach stdout schreiben?
Schreibe Betriebsinformationen nach stderr, nicht nach stdout. stdout enthält ausschließlich MCP-Protokollnachrichten. Schon eine einzelne Logzeile kann das Nachrichten-Framing beschädigen. Die offizielle MCP-Dokumentation zum Debugging sagt das für lokale stdio-Server ausdrücklich.
Was soll ein Client tun, wenn eine MCP-Antwort sein Limit überschreitet?
Höre nach dem Erreichen des Limits nicht einfach auf zu lesen. Leere die Ausgabe weiter und verwirf die Bytes bis zum Ende des fehlerhaften Frames, oder beende den Server und starte die Sitzung neu. Das bloße Stoppen am Limit erzeugt genau die Pipe-Blockade erneut, die du verhindern wolltest.
Wie teste ich MCP-Abbrüche statt nur einen Timeout auszulösen?
Teste drei Eigenschaften getrennt: Der Server bemerkt den Abbruch, seine untergeordnete Arbeit endet, und der Client bleibt nach der abgebrochenen Anfrage verwendbar. Ein Test, der nur eine Abbruchmeldung im Log findet, beweist wenig. Nach dem Abbruch der großen Anfrage muss eine zweite kleine Anfrage zeitnah abgeschlossen werden.
Wie sollten MCP-Tools große Dateien oder API-Antworten zurückgeben?
Verwende Paging, Filter, Zusammenfassungen, stabile Referenzen und eigene Export-Tools. Ein Tool, das einen Verzeichnisbaum, Git-Diff, ein Abfrageergebnis oder einen HTTP-Body liefert, sollte den Teil zurückgeben, der für die aktuelle Entscheidung nötig ist, und anschließend eine Möglichkeit für weitere Daten anbieten. Ein gesamtes Artefakt zu senden, nur weil es sich leicht serialisieren lässt, ist schlechtes Tool-Design.
Beseitigt ein Aktions-Gateway die Risiken von MCP-stdio-Backpressure?
Sallyport führt HTTP- und SSH-Aktionen über seine lokale App und den sp mcp-Shim aus, sodass ein Agent die zugrunde liegenden Zugangsdaten nicht erhält. Das schützt die Geheimnisse, ändert aber nichts an den physikalischen Grenzen von stdout. Tools und Clients brauchen weiterhin Ergebnislimits, Abbruchlogik und Tests, die beweisen, dass eine übergroße Antwort die Sitzung nicht blockieren kann.