Who may do what
A token per case is the more interesting design: it belongs to a run, not to a user.
Milestone The same container serves two endpoints with two auth models, and no session wanders between them.
With stdio the question answers itself before it is asked: authorised is whoever may start the process. As soon as the server has an address, it stands open. And it has more than one right answer.
Model 1: one token for the service
The simplest design, and for internal services usually the right one. A secret in the environment, a bearer header, done. Four lines.
// helpdesk/auth.ts
import { timingSafeEqual } from "node:crypto";
/** Zeitkonstanter Vergleich. `timingSafeEqual` verlangt gleich lange Puffer und
* wirft sonst; dass die Länge durchsickert, ist bei einem Hex-Token aus
* `openssl rand` egal - seine Länge ist ohnehin bekannt. */
export function gleich(a: string, b: string): boolean {
const x = Buffer.from(a, "utf8");
const y = Buffer.from(b, "utf8");
return x.length === y.length && timingSafeEqual(x, y);
}
export function pruefeBetreiber(req: IncomingMessage): boolean {
const erwartet = process.env.HELPDESK_TOKEN;
if (!erwartet) return false;
const kopf = req.headers["authorization"];
return typeof kopf === "string" && gleich(kopf, `Bearer ${erwartet}`);
}
Why timingSafeEqual and not ===? A === on strings breaks at the first
differing character, and that tiny difference becomes measurable over many
attempts, so a token can be guessed character by character. The safe version
costs you four lines.
Two more small things are in there. A missing token counts as not authorised
and not as an open door; a service that lets everybody in when the environment
variable is unset typically only shows up in production. And the answer is 401
without explanation. Whoever has no token need not learn whether there was a
right one.
Model 2: one token per case
The more interesting pattern, and distinctly rarer in the field. The token belongs to no user. It belongs to one run, comes into being with it, dies with it and opens nothing else.
export type Vorgang = { id: string; token: string; bestand: Map<string, Ticket> };
const VORGAENGE = new Map<string, Vorgang>();
export function oeffneVorgang(id: string, token: string): Vorgang {
const vorgang: Vorgang = { id, token, bestand: neuerBestand() };
VORGAENGE.set(token, vorgang);
return vorgang;
}
/** Token → Vorgang. Erst die Form prüfen, dann nachschlagen: Was nicht wie ein
* Token aussieht, kostet keine Suche. `null` heißt 401, ohne zu verraten, ob
* es das Token gab. */
export function loeseVorgang(token: string): Vorgang | null {
if (!/^[a-f0-9]{32}$/.test(token)) return null;
return VORGAENGE.get(token) ?? null;
}
Why the detour? Because it solves three things at once for which you would otherwise need three mechanisms.
The data cut. The case carries its inventory. Whoever presents their token sees their eight tickets and no others, and that is not down to a WHERE clause: the server built for them knows only these.
The lifetime. A user token gets revoked at some point, and for that you need a revocation list. A case token ends when the case ends.
Passing it on. It stands on a page, gets copied into a client configuration and then sits in a file on somebody else's machine, where it will probably still sit weeks after the case is long closed. A user token would be a problem there. This one opens a practice run.
That is how the simulation worlds of this repo work. The sim endpoints
(/sim-calendar, /sim-crm) check a run token from parcours_runs and never
the operator's MCP_AGENT_TOKEN; that belongs to the editorial interface alone.
The token resolver additionally pins the run to its own challenge kind, so a
valid calendar token opens nothing at the CRM.
Both in the same process
One server, two doors. The branch sits in the HTTP handler:
// helpdesk/http.ts
const server = http.createServer(async (req, res) => {
const pfad = (req.url ?? "/").split("?")[0]!;
if (pfad === "/health" && req.method === "GET") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, sitzungen: sitzungen.anzahl }));
return;
}
if (pfad === "/vorgang" || pfad.startsWith("/vorgang/")) {
const token = lies(req, pfad);
const vorgang = token ? loeseVorgang(token) : null;
if (!vorgang) {
res.statusCode = 401;
res.end("Unauthorized");
return;
}
await bediene(req, res, `vorgang:${vorgang.id}`, () => baue(vorgang.bestand));
return;
}
if (pfad !== "/mcp") {
res.statusCode = 404;
res.end();
return;
}
if (!pruefeBetreiber(req)) {
res.statusCode = 401;
res.end("Unauthorized");
return;
}
await bediene(req, res, "betrieb", () => baue(betriebsBestand));
});
The token may sit in the path or in the header:
/** Das Vorgangs-Token steht im Pfad oder im Bearer-Kopf. Beides, weil
* MCP-Clients sich uneinig sind: manche nehmen nur eine URL entgegen. */
function lies(req: http.IncomingMessage, pfad: string): string | null {
const imPfad = /^\/vorgang\/([a-f0-9]{32})\/?$/.exec(pfad);
if (imPfad) return imPfad[1]!;
const kopf = req.headers["authorization"];
const bearer = typeof kopf === "string" ? /^Bearer\s+(\S+)$/.exec(kopf) : null;
return bearer ? bearer[1]! : null;
}
A token in the path is not a pretty thing. It ends up in logs and in browser history. It is necessary anyway, because some clients accept nothing but a URL. For a throwaway case token you may make that trade; for a user token you may not.
The gap you overlook
The token is inspected when the session is built. After that only a session id travels along. What stops somebody from reusing a session opened at the case endpoint at the operations endpoint afterwards?
Without these three lines: nothing.
// Eine Sitzung merkt sich, wofür sie geöffnet wurde. Ohne das könnte eine
// Sitzungs-ID vom Vorgangs-Endpunkt am Betriebs-Endpunkt weiterlaufen - das
// Token wird ja nur beim Aufbau angesehen.
if (bekannt && bekannt.bereich !== bereich) {
res.statusCode = 401;
res.end("Unauthorized");
return;
}
That is why nimmAuf carries a bereich, and why sieh looks up without
touching the clock. A foreign session should be neither reachable nor kept alive
by the mere attempt. The service of this repo has the same comparison in the same
place, with the same reasoning in the comment.
The proof
Four connection attempts against the running server:
A ohne Token: Error: Streamable HTTP error: Error POSTing to endpoint: Unauthorized
B Werkzeuge: 9
C Stats im Vorgang: { offen: 8 }
D falsches Token: Error: Streamable HTTP error: Error POSTing to endpoint: Unauthorized
A is rejected, B gets through with the service token, C sees its own inventory,
D fails on a token of thirty-two f.
What is not here
Permissions per tool. The helpdesk has two doors and behind each of them all nine tools. Whoever gets in may also delete.
For a practice server that is right. For a service on a real inventory it would
be the next step: reading tools for everybody, writing tools for a few. You would
have to rebuild little, because the place is already waiting for it.
registriereHelpdesk gets a second parameter, and what is not registered does
not appear in the tool list either. A tool a model cannot see, it cannot call. No
permission check is more reliable than that.