Operations
A service nobody sweeps and nobody asks falls over exactly when nobody is watching.
Milestone The server runs as its own container with a health check, a session cap and one log line per call.
An MCP server is a service like any other. It starts, it answers, it falls over, and at some point somebody asks why a tool call went wrong two days ago. Then you want to be able to answer.
This chapter takes it that far.
Its own container, not an endpoint of the application
Why not simply a route in the web application? Less work it would be.
Three reasons speak against it, and the third weighs most.
The load looks different. A tool call sometimes takes seconds. A web application with a limited connection pool gets hiccups from that, in a place that has nothing to do with MCP.
Dependencies. The MCP SDK, its transports, its JSON-RPC - none of that belongs in the bundle a browser loads.
Restarts. A deploy of the application takes every open MCP session with it, whereas its own container only restarts when something on the server itself has changed.
That is how this repo solves it too. mcp is its own target in the Dockerfile
and its own service in Compose, with its own database connection. Application and
server share the schema. They do not share the process.
The 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"]
No build step, no tsc, no second stage. Node strips the types while loading,
and npm ci --omit=dev leaves out everything that was only there for developing.
What remains is an image of Node, two packages and six files.
wget instead of curl, because Alpine brings it along and does not bring
curl.
The health check is not a formality
if (pfad === "/health" && req.method === "GET") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, sitzungen: sitzungen.anzahl }));
return;
}
Two rules apply to this endpoint, and both get broken regularly.
It must not demand authorisation. A health check that needs a token will at some point run with the wrong one and send the container into a restart loop.
It must not do anything expensive. Querying a database every thirty seconds checks nothing. That is baseline load.
The session count sits in there because in production it is the most interesting number. If it rises and never falls, the reaper is not sweeping, and you see it before memory shows it to you.
Built and started, it looks like this:
$ 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
One log line per call
A log that only knows errors does not help when it matters. In this repo the experience sits as a dated incident: the Stripe webhook wrote only failures for a long time, and when a purchase plus cancellation had to be reconstructed on 09.08.2026, not one character about it was in the log. Since then it writes a line per processed event.
For the MCP server that means one line per tool call, with scope, tool, duration and the size of the answer.
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;
};
}
What does not belong in there counts just as much. No arguments in plain
text; in answer_ticket sits the reply to a customer. No tokens, not even
shortened. The scope (vorgang:demo) is enough to tell two runs apart, and it
gives nobody away.
And a log stream that lands nowhere is none. Container logs die with their container, every deploy creates a new one, and out of that same lesson the application of this repo mirrors its output into a volume via an entrypoint script.
Two caps and a reaper
From chapter 4, here once more as a matter of operations:
| Brake | against what |
|---|---|
| Idle time plus reaper | the honest client that simply walks away |
| Global cap | many sessions together |
| Cap per scope | a single caller initialising in a loop |
The server of this course has the first two. The third pays off as soon as there
is more than one kind of caller. mcp/sessions.ts in this repo has it and
evicts the oldest session rather than rejecting the new one. That is meant
practically: whoever locks themselves out with a stuck transport would otherwise
not get back in.
What is still missing, and when it is due
For an internal service the server is now fit for operation. Four things are missing, and none of them pays off earlier:
Durable state. The inventory lives in memory and is gone after a restart. Right for a practice run, not for a real helpdesk.
Permissions per tool. Reading for everybody, writing for a few. That is prepared in chapter 5.
Backpressure. A 429 with Retry-After as soon as a caller asks faster than
the service can answer.
A second endpoint. As soon as there is one, the rule from chapter 5 applies without exception: its own token gate, its own scope, and the session remembers what it was opened for.
Which of them comes first is decided by operations and not by this list. The order above has proven itself, because a service whose state does not survive a restart typically gets restarted at the moment somebody needs it.