A domain, not a collection
Nine tools that finish one task are worth more than thirty that half-cover everything.
Milestone The Simhaven helpdesk runs: eight tickets, read, classify, answer, escalate, close, count.
What separates a server an agent uses from one it merely knows about? Not how many tools it has. It hangs on whether a task can be finished to the end with them.
A server with thirty tools that each mirror one API route stays a collection. An agent supposed to work with it has to invent the order itself and then, at some point, fails to find the one tool that would let it close the case. Nine tools that together clear an inbox are a domain. The difference is not size.
The scenery
The Simhaven helpdesk. Eight tickets sit in the inbox, all about the same product, a water level sensor called Tidenwächter TW-2. Four of them are product questions whose answer is in the manual. One reports a fault. One complains about a double charge. One threatens to sue. And one is spam.
That mix is deliberate. An agent that answers everything because answering feels helpful makes two mistakes at once: it answers the lawyer and it answers the troll. The scenery comes from the parcours task The support service, where members compete against exactly that.
The inventory
Everything lives in process memory. That is not a simplification for the tutorial; it is the right size for a server that serves one bounded case. A database may join in once the state has to survive a restart.
// helpdesk/welt.ts
export const KATEGORIEN = ["frage", "stoerung", "rechnung", "recht", "spam"] as const;
export type Kategorie = (typeof KATEGORIEN)[number];
export const STATUS = ["offen", "beantwortet", "eskaliert", "geschlossen"] as const;
export type Status = (typeof STATUS)[number];
export type Ticket = {
key: string;
absender: string;
betreff: string;
text: string;
eingang: string;
kategorie: Kategorie | null;
status: Status;
antwort: string | null;
eskaliertAn: string | null;
};
/** Die Produktdoku. Die einzige Faktenquelle des Helpdesks. */
export const DOKU = {
produkt: "Tidenwächter TW-2",
hersteller: "Nordwerk Simhaven",
garantieMonate: 24,
ruecksendeTage: 14,
batterieMonate: 18,
tauchtiefeMeter: 3,
lieferzeitTage: 5,
} as const;
The eight tickets sit next to it as a list - sender, subject, text, arrival - and a function turns them into a fresh inventory:
export function neuerBestand(runden = 1): Map<string, Ticket> {
const bestand = new Map<string, Ticket>();
let nummer = 1041;
for (let runde = 0; runde < runden; runde++) {
for (const t of ROH) {
const key = `T-${nummer++}`;
bestand.set(key, {
...t,
key,
kategorie: null,
status: "offen" as Status,
antwort: null,
eskaliertAn: null,
});
}
}
return bestand;
}
Why a function and not a constant? Because every session should get its own
inventory. That is chapter 4, and that is why the inventory here is already a
return value rather than a module variable. The runden parameter looks
superfluous. In chapter 6 it turns eight tickets into four hundred and exposes a
failure mode that stays invisible at eight.
Nine tools
The split follows the sequence in a caseworker's head: first see what is there, then open individual cases, then act, then check whether you are through. Database tables typically play no part in it.
| Tool | for what |
|---|---|
list_tickets | the overview, without full texts |
get_ticket | open one case completely |
search_tickets | search subject and body |
read_doc | the product manual - the only source of facts |
categorize_ticket | classify |
answer_ticket | answer and set to beantwortet |
escalate_ticket | hand on, with a reason |
close_ticket | close, even without an answer |
helpdesk_stats | count what is still open |
read_doc is the borderline case in that list. By the pure doctrine of the
protocol, should the product manual not be a resource rather than a tool? It
gets read, it causes nothing. Only, far from all clients support resources, and a
source of facts your agent cannot reach is worth nothing. So a tool, with a
clear description. Why the protocol distinguishes them anyway is in the
component.
Two helpers that carry everything
Before the first tool, two functions that appear in all nine:
// helpdesk/werkzeuge.ts
/** Ein Ergebnis. Immer JSON in einem Text-Block: Was der Client zeigt, sieht
* das Modell wörtlich, und was es sieht, soll es parsen können. */
function json(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
}
function fehler(text: string) {
return {
isError: true,
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: text }) }],
};
}
The two differ in isError. A tool that cannot do what it is asked should answer
with fehler() and not throw an exception. A thrown exception can in the worst
case take the session with it, whereas the text from fehler() lands at the model
and usually triggers something there that looks like thinking.
The third helper builds the row of a list, and it decides more than it looks:
/** Die Zeile einer Liste — ohne `text`. Acht Volltexte sind acht Kilobyte
* Kontext für eine Frage, die nach Betreffzeilen verlangt. */
function zeile(t: Ticket) {
return {
key: t.key,
absender: t.absender,
betreff: t.betreff,
eingang: t.eingang,
kategorie: t.kategorie,
status: t.status,
};
}
The first real tool
export function registriereHelpdesk(server: McpServer, bestand = neuerBestand()) {
server.registerTool(
"list_tickets",
{
description:
"Listet die Tickets des Helpdesks - Betreffzeile, Absender, Eingang, Kategorie, Status. " +
"`status` und `kategorie` grenzen ein, `limit` und `offset` blättern (Vorgabe 25, Höchstwert 100). " +
"Der Volltext eines Tickets steht hier NICHT, den holt `get_ticket`.",
inputSchema: {
status: z.enum(["offen", "beantwortet", "eskaliert", "geschlossen"]).optional(),
kategorie: z.enum(KATEGORIEN).optional(),
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(),
},
},
async ({ status, kategorie, limit = 25, offset = 0 }) => {
const alle = [...bestand.values()];
const treffer = alle.filter(
(t) => (!status || t.status === status) && (!kategorie || t.kategorie === kategorie),
);
const seite = treffer.slice(offset, offset + limit);
return json({
ok: true,
gesamt: alle.length,
treffer: treffer.length,
offset,
rest: Math.max(0, treffer.length - offset - seite.length),
tickets: seite.map(zeile),
});
},
);
The inventory comes in as a parameter, with a fresh one as the default. The whole registration is therefore a function over an inventory. That is why every session can later get its own world without a single line of your tool code changing.
rest is a small thing with a large effect. A model that receives a list of 25
entries cannot know without that field whether it is done. With it, it pages on
or stops.
Read, act, count
The remaining eight follow the same pattern. Two of them are worth a look:
server.registerTool(
"answer_ticket",
{
description:
"Schreibt die Antwort an den Absender und setzt das Ticket auf `beantwortet`. " +
"Ein geschlossenes Ticket nimmt keine Antwort mehr an.",
inputSchema: {
key: z.string().regex(/^T-\d{4}$/),
antwort: z.string().min(10).max(4000),
},
},
async ({ key, antwort }) => {
const t = bestand.get(key);
if (!t) return fehler(`unbekanntes Ticket: ${key}`);
if (t.status === "geschlossen") return fehler(`${key} ist geschlossen`);
t.antwort = antwort;
t.status = "beantwortet";
return json({ ok: true, ticket: zeile(t) });
},
);
server.registerTool(
"escalate_ticket",
{
description:
"Gibt ein Ticket an ein anderes Team ab, mit Begründung. " +
"Danach steht es auf `eskaliert` und wird nicht mehr beantwortet.",
inputSchema: {
key: z.string().regex(/^T-\d{4}$/),
team: z.enum(["recht", "technik", "buchhaltung"]),
grund: z.string().min(10).max(500),
},
},
async ({ key, team, grund }) => {
const t = bestand.get(key);
if (!t) return fehler(`unbekanntes Ticket: ${key}`);
t.eskaliertAn = team;
t.status = "eskaliert";
t.antwort = `[eskaliert an ${team}] ${grund}`;
return json({ ok: true, ticket: zeile(t) });
},
);
antwort has .min(10). Ten characters check no quality. They catch the call
with which a model ticks a ticket off as „ok“.
And close_ticket requires no answer. That is what makes this domain nasty.
Spam gets closed and not answered. A server that couples closing to
an answer would already have decided the task for the agent.
What the server cannot do
There is no handle_all_tickets. No tool that guesses the category. And no
answer_from_doc that assembles the answer from the product manual.
The CRM server of this repo draws the same line (mcp/tools/sim_crm.ts). It
deliberately lacks a merge_contacts, because merging two records is exactly the
work that is at stake. A tool that does the task makes the agent redundant and
the server useless for everything that lies even slightly differently.
The line runs where something stops being able and starts judging. Read, create, change, delete, count is what the server can do. What goes where you had better not decide for it. That stays the agent's business.
Connecting it
The start file for stdio is seven lines long:
// helpdesk/stdio.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registriereHelpdesk } from "./werkzeuge.ts";
const server = new McpServer({ name: "simhaven-helpdesk", version: "1.0.0" });
registriereHelpdesk(server);
await server.connect(new StdioServerTransport());
A run over the test rig from chapter 1, with the real output:
WERKZEUGE: list_tickets, get_ticket, search_tickets, read_doc,
categorize_ticket, answer_ticket, escalate_ticket,
close_ticket, helpdesk_stats
DOKU: {"ok":true,"doku":{"produkt":"Tidenwächter TW-2", ... "garantieMonate":24, ...}}
ESKALATION: {"ok":true,"ticket":{"key":"T-1045", ... "status":"eskaliert"}}
STATS: {
"ok": true,
"gesamt": 8,
"nachStatus": { "beantwortet": 1, "offen": 5, "eskaliert": 1, "geschlossen": 1 },
"nachKategorie": { "ohne": 7, "recht": 1 }
}
Eight tickets, one escalated lawyer, one closed troll, five open cases. The server does something.