30 min

Zustand und Sitzungen

Ein geteilter Transport weist das zweite `initialize` ab - und eine Sitzung muss wissen, wofür sie geöffnet wurde.

Meilenstein Zwei Clients arbeiten gleichzeitig in getrennten Beständen, und der Reaper räumt beide wieder weg.

Über stdio stellt sich die Sitzungsfrage nicht. Ein Client, ein Prozess, ein Bestand; wenn der Client geht, geht der Prozess mit. Deshalb ist stdio die einfachere Betriebsform.

Über HTTP musst du jedes davon entscheiden.

Der Fehler, den jeder einmal baut

Der naheliegende Weg: ein Transport, ein Server, ein createServer darüber.

// helpdesk/geteilt.ts — so NICHT
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
const server = new McpServer({ name: "simhaven-helpdesk", version: "1.0.0" });
registriereHelpdesk(server);
await server.connect(transport);

http.createServer(async (req, res) => {
  await transport.handleRequest(req, res);
}).listen(8788);

Das läuft. Für einen Client. Der zweite bekommt das hier:

Client 1: verbunden, 9 Werkzeuge
Client 2: Error: Streamable HTTP error: Error POSTing to endpoint:
{"jsonrpc":"2.0","error":{"code":-32600,
 "message":"Invalid Request: Server already initialized"},"id":null}

Ein Transport kann genau eine Sitzung. Das initialize aus dem Dreitakt ist kein Formular, das du mehrfach ausfüllen darfst; es baut diese eine Verbindung auf. Wer den Transport teilt, teilt die Sitzung, und die ist schon vergeben.

Im MCP-Dienst dieses Repos steht der Satz als Kommentar über der Sitzungs-Map (mcp/server.ts), damit ihn niemand ein zweites Mal herausfinden muss.

Ein Paar je Client

Die Bauform lautet deshalb: pro Sitzung ein Transport und ein McpServer. Beim ersten Kontakt entsteht das Paar, die vergebene Sitzungs-ID wandert in eine Map, und jede weitere Anfrage findet über den Mcp-Session-Id-Kopf zurück.

async function bediene(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  bereich: string,
  baueServer: () => McpServer,
) {
  const kopf = req.headers["mcp-session-id"];
  const id = Array.isArray(kopf) ? kopf[0] : kopf;
  const bekannt = id ? sitzungen.sieh(id) : undefined;

  if (bekannt) {
    sitzungen.beruehre(bekannt.id);
    await bekannt.wert.handleRequest(req, res);
    return;
  }

  const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => randomUUID(),
    onsessioninitialized: (sid) => sitzungen.nimmAuf(sid, bereich, transport),
  });
  transport.onclose = () => {
    if (transport.sessionId) sitzungen.streiche(transport.sessionId);
  };
  await baueServer().connect(transport);
  await transport.handleRequest(req, res);

  // Nur ein `initialize` führt in die Registry. Jede andere Anfrage ohne
  // gültige Sitzungs-ID ließe Transport und Server sonst unregistriert liegen.
  if (!transport.sessionId || !sitzungen.sieh(transport.sessionId)) {
    try {
      await transport.close();
    } catch {
      // Schon zu, oder nie richtig auf.
    }
  }
}

Der Nachsatz am Ende ist wichtiger, als er aussieht. onsessioninitialized feuert nur bei einem gelungenen initialize. Eine Anfrage mit abgelaufener Sitzungs-ID, ein Client, der gleich mit tools/list anfängt, ein initialize, das an der Protokollversion scheitert: In all diesen Fällen ist gerade ein Transport samt Server gebaut worden, den niemand kennt und niemand schließt. Ohne die drei Zeilen wächst der Speicher bei jedem Fehlversuch.

Jede Sitzung ihre Welt

baueServer ist eine Funktion, kein Objekt. Damit lässt sich entscheiden, welchen Bestand ein Client zu sehen bekommt:

function baue(bestand = neuerBestand()): McpServer {
  const server = new McpServer({ name: "simhaven-helpdesk", version: "1.0.0" });
  registriereHelpdesk(server, bestand);
  return server;
}

Der Bestand steckt im Closure der Werkzeuge. Kein Aufruf kann an ihm vorbei, und keiner kann in einen fremden greifen. Das verhindert keine Prüfung; es gibt den anderen Bestand in diesem Server schlicht nicht. Dasselbe Prinzip trägt die Simulationswelten dieses Repos: In mcp/server.ts bekommt jeder Parcours-Lauf seine eigene McpServer-Instanz, und der Lauf steckt im Closure statt in einem Parameter.

Ob geteilt oder getrennt, entscheidet die Sache selbst. Ein Helpdesk, an dem ein Team arbeitet, hat einen Bestand für alle. Ein Übungslauf hat seinen eigenen. Der Server dieses Kurses kann beides und entscheidet am Endpunkt, welches gilt. Das ist Kapitel 5.

Wer räumt auf

Ein Client, der geht, sollte DELETE schicken. Muss er aber nicht, und viele tun es nicht. Was passiert dann?

Die Map wächst. Kleiner wird sie nur bei einem DELETE, das kein Client schicken muss, und bei einem onclose, das ohne DELETE nicht kommt. Ein Aufrufer, der in Schleife initialisiert, füllt damit den Prozessspeicher, bis der Container fällt.

Zwei Bremsen, jede für einen anderen Fall:

// helpdesk/sitzungen.ts
export class Sitzungen<T extends Schliessbar> {
  readonly #alle = new Map<string, Sitzung<T>>();
  readonly #leerlaufMs: number;
  readonly #maximum: number;
  readonly #jetzt: () => number;

  constructor(leerlaufMs: number, maximum: number, jetzt: () => number = () => Date.now()) {
    this.#leerlaufMs = leerlaufMs;
    this.#maximum = maximum;
    this.#jetzt = jetzt;
  }

  /** Nachschlagen, **ohne** die Uhr zu stellen: Ein Fremder mit geratener
   *  Sitzungs-ID soll sie nicht am Leben halten. */
  sieh(id: string): Sitzung<T> | undefined {
    return this.#alle.get(id);
  }

  beruehre(id: string): void {
    const s = this.#alle.get(id);
    if (s) s.zuletzt = this.#jetzt();
  }

  nimmAuf(id: string, bereich: string, wert: T): void {
    this.#alle.set(id, { id, bereich, wert, zuletzt: this.#jetzt() });
    while (this.#alle.size > this.#maximum) {
      const aeltester = [...this.#alle.values()].sort((a, b) => a.zuletzt - b.zuletzt)[0];
      if (!aeltester) break;
      this.#wirfRaus(aeltester);
    }
  }

  raeume(): number {
    const grenze = this.#jetzt() - this.#leerlaufMs;
    let n = 0;
    for (const s of [...this.#alle.values()]) {
      if (s.zuletzt <= grenze) {
        this.#wirfRaus(s);
        n++;
      }
    }
    return n;
  }

  starteReaper(taktMs: number): () => void {
    const timer = setInterval(() => this.raeume(), taktMs);
    timer.unref?.();
    return () => clearInterval(timer);
  }

  /** Erst austragen, dann schließen: Das `onclose` des Transports ruft
   *  `streiche()` und fände sonst genau diesen Eintrag noch vor. */
  #wirfRaus(s: Sitzung<T>): void {
    if (this.#alle.get(s.id) !== s) return;
    this.#alle.delete(s.id);
    try {
      void s.wert.close();
    } catch {
      // Ein Transport, der beim Schließen wirft, ist trotzdem aus der Map.
    }
  }
}

Vier Kleinigkeiten darin sollte man beim Namen nennen.

sieh stellt die Uhr nicht. Nachschlagen und Berühren sind getrennt, weil in Kapitel 5 noch etwas dazwischen geprüft wird: Wer eine fremde Sitzungs-ID vorlegt, soll sie nicht durch den bloßen Versuch am Leben halten.

Der Reaper hängt an unref. Ohne das hält der Timer den Prozess wach, nachdem der HTTP-Server längst zu ist, und docker stop wartet dann zehn Sekunden auf ein SIGKILL, das gar nicht nötig gewesen wäre.

Erst austragen, dann schließen. Das close() löst onclose aus, und das ruft streiche(). Andersherum stünde der Eintrag noch in der Map, während er gerade entfernt wird.

Die Uhr ist ein Parameter. jetzt steht im Konstruktor, damit du den Reaper testen kannst, ohne fünfzehn Minuten zu warten.

Angemeldet wird das im Serverstart, mit zwei Zahlen:

const LEERLAUF_MS = 15 * 60_000;
const MAX_SITZUNGEN = 200;

const sitzungen = new Sitzungen<StreamableHTTPServerTransport>(LEERLAUF_MS, MAX_SITZUNGEN);
sitzungen.starteReaper(60_000);

Fünfzehn Minuten sind großzügig genug für einen Agenten, der zwischen zwei Werkzeugaufrufen nachdenkt, und knapp genug, dass ein Sitzungs-Sturm nicht stundenlang nachhallt. Der Dienst dieses Repos steht auf denselben Werten (mcp/sessions.ts) und hat zusätzlich einen Deckel je Bereich, damit ein einzelner Lauf nicht die ganze Registry füllen kann.

Prüfen, dass es stimmt

Zwei Clients, zwei getrennte Welten, im selben Prozess:

B Stats: { offen: 7, geschlossen: 1 }
C Stats im Vorgang: { offen: 8 }
H health: {"ok":true,"sitzungen":2}

Der erste Client hat ein Ticket geschlossen. Der zweite sieht davon nichts, und der Server weiß von beiden.