Ein MCP-Server, der über stdio spricht, hängt hinter supergateway und ist damit per Streamable HTTP erreichbar. Läuft, Tool-Calls kommen zurück. Irgendwann steht der Server: RAM und Swap voll, Load hoch, jeder Tool-Call läuft in ein Timeout. Schuld ist der Standardmodus von supergateway. Er startet für jeden einzelnen Request einen neuen Kindprozess und beendet ihn nie.
Getestet mit
- supergateway 3.4.3
- @modelcontextprotocol/sdk 1.30.0
- Node 22.23.2, npm 10.9.8
- Ubuntu 22.04, curl 7.81.0
- pm2 als Prozessmanager (Schritt 4)
Schritt 1: Einen kleinen stdio-Server zum Testen
Damit du das Verhalten ohne echten Server nachstellen kannst, reicht ein Echo-Tool. Das Skript liest JSON-RPC von stdin und antwortet auf stdout, mehr macht ein stdio-MCP-Server nicht.
mkdir -p /opt/mcp-test && cd /opt/mcp-test
npm init -y
npm install supergateway@3.4.3 @modelcontextprotocol/sdk zod
Datei server.mjs:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "demo", version: "1.0.0" });
server.tool("echo", { text: z.string() }, async ({ text }) => ({
content: [{ type: "text", text: `echo: ${text}` }],
}));
await server.connect(new StdioServerTransport());
Schritt 2: supergateway so starten, wie es die Doku zeigt
Das ist die Zeile aus dem README, Abschnitt „stdio → Streamable HTTP“, nur mit eigenem Pfad und Port:
node node_modules/supergateway/dist/index.js
--stdio "node /opt/mcp-test/server.mjs"
--outputTransport streamableHttp
--port 4001 --streamableHttpPath /demo
Ein Tool-Call per curl. Streamable HTTP will beide Accept-Typen sehen, sonst gibt es einen 406:
curl -s -X POST http://127.0.0.1:4001/demo
-H 'Content-Type: application/json'
-H 'Accept: application/json, text/event-stream'
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hallo"}}}'
Antwort:
event: message
data: {"result":{"content":[{"type":"text","text":"echo: hallo"}]},"jsonrpc":"2.0","id":1}
Funktioniert. Genau das ist das Problem, denn man hört an dieser Stelle auf zu prüfen.
Warum nach 25 Tool-Calls 25 Node-Prozesse liefen
Auf dem Server sah es so aus: RAM und Swap voll, Load hoch, und der Client meldete bei jedem Tool-Call nur noch „server isn’t responding“. Der Gateway-Prozess selbst lief. Sichtbar wird der Fehler erst in der Prozessliste als Baum:
ps -eo pid,ppid,etimes,rss,args --forest | grep -A40 supergateway
Unter dem einen Gateway-Prozess hingen Dutzende Paare aus /bin/sh -c node … und node …, alle noch am Leben, obwohl ihr Request längst beantwortet war. Nachgestellt mit dem Echo-Server oben, 25 Requests hintereinander:
for i in $(seq 1 25); do
curl -s -o /dev/null -X POST http://127.0.0.1:4001/demo
-H 'Content-Type: application/json'
-H 'Accept: application/json, text/event-stream'
-d "{"jsonrpc":"2.0","id":$i,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hallo-$i"}}}"
done
sleep 3
ps -eo args | grep -c '^node /opt/mcp-test/server.mjs'
ps -eo rss,args | awk '/^ *[0-9]+ node /opt/mcp-test/server.mjs/{s+=$1} END{print s " kB"}'
Ergebnis: 25 Kindprozesse, zusammen rund 1,5 GB RSS. Nach zwei Minuten immer noch 25. Sie verschwinden erst, wenn der Gateway-Prozess selbst beendet wird, weil dann ihr stdin zugeht.
Es lag am Standardmodus. Ohne --stateful läuft supergateway „stateless“, und das heißt im Code wörtlich: pro POST-Request ein neuer Server, ein neuer Transport, ein neuer spawn(). Der Kindprozess wird nur bei transport.onclose beendet. Dieses Event kommt bei einem einfachen Request/Response aber nie, weil niemand den Transport explizit schließt. Der Prozess bleibt hängen und wartet auf stdin. Das README nennt --stateful als Option und zeigt den stateless-Aufruf als erstes Beispiel. Dass der Standardmodus pro Request einen Prozess liegen lässt, steht dort nicht.
Schritt 3: Stateful mit Session-Timeout
Im stateful-Modus bekommt jeder Client beim initialize eine Session-Id, und alle weiteren Requests dieser Session laufen über denselben Kindprozess. --sessionTimeout räumt Sessions weg, die nicht mehr genutzt werden. Ohne Timeout bleibt eine Session so lange, bis der Client sie per DELETE beendet, und das machen nicht alle Clients.
node node_modules/supergateway/dist/index.js
--stdio "node /opt/mcp-test/server.mjs"
--outputTransport streamableHttp
--port 4001 --streamableHttpPath /demo
--stateful --sessionTimeout 600000
600000 ms sind zehn Minuten. Der Timer zählt ab dem letzten Zugriff, nicht ab Session-Start. Für einen Client, der alle paar Minuten einen Tool-Call schickt, bleibt die Session also offen.
Schritt 4: Als pm2-Prozess
Wenn der Gateway per pm2 läuft, gehören die beiden Schalter in die ecosystem.config.js. Ich starte supergateway direkt über den Node-Pfad, nicht über npx. So ist die Version festgenagelt, und pm2 überwacht den Gateway-Prozess selbst statt eines Wrappers.
module.exports = {
apps: [{
name: "mcp-demo",
script: "/opt/mcp-test/node_modules/supergateway/dist/index.js",
args: [
"--stdio", "node /opt/mcp-test/server.mjs",
"--outputTransport", "streamableHttp",
"--port", "4001",
"--streamableHttpPath", "/demo",
"--stateful",
"--sessionTimeout", "600000"
],
env: {
WORDPRESS_URL: "https://DEINE-DOMAIN.AT",
WORDPRESS_USER: "DEIN-USER",
WORDPRESS_APP_PASSWORD: "DEIN-APP-PASSWORT"
}
}]
};
Die env-Zeilen sind nur ein Beispiel dafür, wie der stdio-Server seine Zugangsdaten bekommt. Das Echo-Skript braucht keine. Danach:
pm2 start ecosystem.config.js
pm2 save
Wenn vorher schon eine Instanz ohne die Schalter lief: pm2 delete auf den alten Namen, nicht nur pm2 restart. Die verwaisten Kindprozesse sterben erst mit dem alten Gateway-Prozess.
Kontrolle
Zwei Sessions mit je einem initialize und drei Tool-Calls, danach die Kindprozesse zählen. Zum Testen den Timeout kurz setzen, etwa --sessionTimeout 15000.
URL=http://127.0.0.1:4001/demo
SID=$(curl -s -D - -o /dev/null -X POST "$URL"
-H 'Content-Type: application/json'
-H 'Accept: application/json, text/event-stream'
-d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
| awk 'tolower($1)=="mcp-session-id:"{print $2}' | tr -d 'r')
echo "Session: $SID"
for i in 1 2 3; do
curl -s -o /dev/null -X POST "$URL"
-H 'Content-Type: application/json'
-H 'Accept: application/json, text/event-stream'
-H "Mcp-Session-Id: $SID"
-d "{"jsonrpc":"2.0","id":$i,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hallo-$i"}}}"
done
ps -eo args | grep -c '^node /opt/mcp-test/server.mjs'
Erwartet: genau 1 Kindprozess pro Session, egal wie viele Tool-Calls darüber laufen. Nach Ablauf des Timeouts ohne weiteren Zugriff:
sleep 20; ps -eo args | grep -c '^node /opt/mcp-test/server.mjs'
Erwartet: 0. In meinem Lauf waren es nach zwei Sessions zwei Prozesse, nach 27 Sekunden keiner mehr.
Häufige Fehler
Der Tool-Call liefert „Bad Request: No valid session ID provided“. Das ist der stateful-Modus, der einen Request ohne Mcp-Session-Id ablehnt. Der Client muss zuerst initialize schicken und die Id aus dem Antwort-Header übernehmen. Claude Desktop, Claude Code und die meisten MCP-Clients machen das von selbst. Ein Skript mit blankem curl muss es selbst machen, siehe Kontrolle.
Die Prozesse verschwinden auch im stateful-Modus nicht. Dann fehlt --sessionTimeout. Ohne den Wert lebt eine Session, bis der Client sie mit einem DELETE auf denselben Pfad beendet. In meinem Test lief das sauber, aber verlass dich nicht darauf, dass jeder Client das tut.
Der Client meldet nach dem Umstellen „server isn’t responding“. Wahrscheinlich läuft der alte Gateway-Prozess noch und hält den Port. ss -ltnp | grep 4001 zeigt, wer den Port hält.
Ich habe mehrere stdio-Server auf einem Host. Jeder braucht einen eigenen Port und einen eigenen pm2-Eintrag mit beiden Schaltern. Bei mir lief neben der neuen Instanz noch eine ältere ohne die Schalter, und die hatte inzwischen mehrere hundert Kindprozesse. ps -eo ppid= | sort | uniq -c | sort -rn | head zeigt, welche Eltern-PID die meisten Kinder hat.