25 min

Betrieb

Ein Dienst, den niemand räumt und niemand befragt, fällt genau dann, wenn keiner hinsieht.

Meilenstein Der Server läuft als eigener Container mit Gesundheitsprüfung, Sitzungsdeckel und einer Logzeile je Aufruf.

Ein MCP-Server ist ein Dienst wie jeder andere. Er startet, er antwortet, er fällt um, und irgendwann fragt jemand, warum ein Werkzeugaufruf vor zwei Tagen schiefgegangen ist. Dann willst du antworten können.

Dieses Kapitel bringt ihn so weit.

Ein eigener Container, nicht ein Endpunkt der Anwendung

Warum nicht einfach eine Route in der Webanwendung? Weniger Arbeit wäre das.

Drei Gründe sprechen dagegen, und der dritte zählt am meisten.

Die Last sieht anders aus. Ein Werkzeugaufruf dauert manchmal Sekunden. Eine Webanwendung mit begrenztem Verbindungs-Pool bekommt davon Schluckauf, und zwar an einer Stelle, die mit MCP nichts zu tun hat.

Abhängigkeiten. Das MCP-SDK, seine Transporte, sein JSON-RPC - all das gehört nicht in das Bündel, das ein Browser lädt.

Neustarts. Ein Deploy der Anwendung nimmt jede offene MCP-Sitzung mit, während ein eigener Container nur dann neu startet, wenn sich am Server selbst etwas geändert hat.

So ist es auch in diesem Repo gelöst. mcp ist ein eigenes Ziel im Dockerfile und ein eigener Dienst im Compose, mit eigener Datenbankverbindung. Gemeinsam haben Anwendung und Server das Schema. Den Prozess teilen sie nicht.

Das Dockerfile

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV HELPDESK_PORT=8787
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY helpdesk ./helpdesk
EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD wget -qO- http://127.0.0.1:8787/health || exit 1
CMD ["node", "--experimental-strip-types", "helpdesk/http.ts"]

Kein Build-Schritt, kein tsc, keine zweite Stufe. Node entpackt die Typen beim Laden, und npm ci --omit=dev lässt alles weg, was nur zum Entwickeln da war. Übrig bleibt ein Bild aus Node, zwei Paketen und sechs Dateien.

wget statt curl, weil Alpine es mitbringt und curl nicht.

Die Gesundheitsprüfung ist keine Formsache

if (pfad === "/health" && req.method === "GET") {
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ ok: true, sitzungen: sitzungen.anzahl }));
  return;
}

Zwei Regeln gelten für diesen Endpunkt, und beide werden regelmäßig gebrochen.

Er darf keine Berechtigung verlangen. Ein Healthcheck, der ein Token braucht, läuft irgendwann mit dem falschen und schickt den Container in eine Neustartschleife.

Er darf nichts Teures tun. Alle dreißig Sekunden eine Datenbank zu befragen prüft nichts. Das ist Grundlast.

Die Sitzungszahl steht dabei, weil sie im Betrieb die interessanteste ist. Steigt sie und fällt nie, räumt der Reaper nicht, und du siehst es, bevor der Speicher es dir zeigt.

Gebaut und gestartet sieht das so aus:

$ curl -s localhost:18787/health
{"ok":true,"sitzungen":0}

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:18787/mcp
401

$ docker inspect --format '{{.State.Health.Status}}' helpdesk
healthy

Eine Logzeile je Aufruf

Ein Log, das nur Fehler kennt, hilft dann nicht, wenn es darauf ankommt. In diesem Repo steht die Erfahrung als datierter Vorfall: Der Stripe-Webhook schrieb lange nur Fehlschläge, und als am 09.08.2026 ein Kauf samt Kündigung nachzuvollziehen war, stand darüber kein einziges Zeichen im Log. Seitdem schreibt er eine Zeile je verarbeitetem Ereignis.

Für den MCP-Server heißt das: eine Zeile je Werkzeugaufruf, mit Bereich, Werkzeug, Dauer und Größe der Antwort.

function mitProtokoll(bereich: string, name: string, rumpf: Function) {
  return async (args: Record<string, unknown>) => {
    const start = Date.now();
    const ergebnis = await rumpf(args);
    const zeichen = JSON.stringify(ergebnis).length;
    console.log(
      JSON.stringify({
        t: new Date().toISOString(),
        bereich,
        werkzeug: name,
        ms: Date.now() - start,
        zeichen,
        fehler: Boolean((ergebnis as { isError?: boolean }).isError),
      }),
    );
    return ergebnis;
  };
}

Was nicht hineingehört, zählt genauso. Keine Argumente im Klartext; in answer_ticket steht der Antworttext an einen Kunden. Keine Tokens, auch nicht gekürzt. Der Bereich (vorgang:demo) reicht, um zwei Läufe auseinanderzuhalten, und verrät niemanden.

Und ein Logstrom, der nirgends landet, ist keiner. Container-Logs sterben mit ihrem Container, jeder Deploy erzeugt einen neuen, und aus derselben Lektion spiegelt die Anwendung dieses Repos ihre Ausgabe über ein Entrypoint-Skript in ein Volume.

Zwei Deckel und ein Reaper

Aus Kapitel 4, hier noch einmal als Betriebsangelegenheit:

Bremsewogegen
Leerlauf plus Reaperder ehrliche Client, der einfach weggeht
Deckel gesamtviele Sitzungen zusammen
Deckel je Bereichein einzelner Aufrufer, der in Schleife initialisiert

Der Server dieses Kurses hat die ersten beiden. Der dritte lohnt sich, sobald es mehr als eine Sorte Aufrufer gibt. mcp/sessions.ts in diesem Repo hat ihn und verdrängt dabei die älteste Sitzung, statt die neue abzulehnen. Das ist praktisch gemeint: Wer sich mit einem hängengebliebenen Transport selbst aussperrt, käme sonst nicht mehr herein.

Was noch fehlt, und wann es dran ist

Für einen internen Dienst ist der Server jetzt betriebstauglich. Vier Dinge fehlen ihm, und keines davon lohnt sich früher:

Dauerhafter Zustand. Der Bestand liegt im Speicher und ist nach einem Neustart weg. Für einen Übungslauf richtig, für einen echten Helpdesk nicht.

Rechte je Werkzeug. Lesen für alle, Schreiben für wenige. Vorbereitet ist das schon in Kapitel 5.

Rücklauf bei Überlast. Ein 429 mit Retry-After, sobald ein Aufrufer schneller fragt, als der Dienst antworten kann.

Ein zweiter Endpunkt. Sobald es einen gibt, gilt die Regel aus Kapitel 5 ohne Ausnahme: eigenes Token-Tor, eigener Bereich, und die Sitzung merkt sich, wofür sie geöffnet wurde.

Was davon zuerst kommt, entscheidet der Betrieb und nicht diese Liste. Die Reihenfolge von oben hat sich erfahrungsgemäß bewährt, denn ein Dienst, dessen Zustand einen Neustart nicht überlebt, wird erfahrungsgemäß in dem Moment neu gestartet, in dem jemand ihn braucht.