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.