30 min

State and sessions

A shared transport rejects the second `initialize` - and a session has to remember what it was opened for.

Milestone Two clients work in separate inventories at the same time, and the reaper clears both away again.

Over stdio the session question does not arise. One client, one process, one inventory; when the client leaves, the process leaves with it. That is why stdio is the simpler way to operate.

Over HTTP you have to decide each of these.

The mistake everyone makes once

The obvious way: one transport, one server, one createServer on top.

// 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);

That runs. For one client. The second one gets this:

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}

A transport can do exactly one session. The initialize from the three-beat is not a form you may fill in repeatedly; it builds this one connection. Whoever shares the transport shares the session, and that one is taken.

In the MCP service of this repo the sentence sits as a comment above the session map (mcp/server.ts), so that nobody has to find it out a second time.

One pair per client

So the design reads: one transport and one McpServer per session. The pair comes into being on first contact, the assigned session id goes into a map, and every further request finds its way back via the Mcp-Session-Id header.

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.
    }
  }
}

The trailing block matters more than it looks. onsessioninitialized only fires on a successful initialize. A request with an expired session id, a client that starts straight with tools/list, an initialize that fails on the protocol version: in all of these a transport and a server have just been built that nobody knows and nobody closes. Without those three lines memory grows with every failed attempt.

Every session its own world

baueServer is a function, not an object. That lets you decide which inventory a client gets to see:

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

The inventory sits in the closure of the tools. No call can get past it, and none can reach into a foreign one. No check prevents that; the other inventory simply does not exist in this server. The same principle carries the simulation worlds of this repo: in mcp/server.ts every parcours run gets its own McpServer instance, and the run sits in the closure instead of in a parameter.

Whether shared or separate is decided by the subject matter. A helpdesk worked by one team has one inventory for everybody. A practice run has its own. The server of this course can do both and decides at the endpoint which one applies. That is chapter 5.

Who clears up

A client that leaves should send DELETE. It does not have to, and many do not. What happens then?

The map grows. It only shrinks on a DELETE no client must send, and on an onclose that does not come without a DELETE. A caller that initialises in a loop fills process memory that way until the container falls over.

Two brakes, each for a different case:

// 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.
    }
  }
}

Four small things in there deserve to be named.

sieh does not touch the clock. Looking up and touching are separate, because in chapter 5 something gets checked in between: whoever presents a foreign session id should not keep it alive by the mere attempt.

The reaper hangs on unref. Without it the timer keeps the process awake after the HTTP server is long closed, and docker stop then waits ten seconds for a SIGKILL that was never necessary.

Deregister first, close second. The close() triggers onclose, and that calls streiche(). The other way round the entry would still be in the map while it is being removed.

The clock is a parameter. jetzt sits in the constructor so you can test the reaper without waiting fifteen minutes.

You wire it up at server start, with two numbers:

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

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

Fifteen minutes are generous enough for an agent that thinks between two tool calls, and tight enough that a session storm does not echo for hours. The service of this repo runs on the same values (mcp/sessions.ts) and additionally has a cap per scope, so that a single run cannot fill the whole registry.

Checking that it holds

Two clients, two separate worlds, in the same process:

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

The first client closed a ticket. The second sees nothing of it, and the server knows about both.