Eine Domäne statt einer Sammlung
Neun Werkzeuge, die eine Aufgabe zu Ende bringen, sind mehr wert als dreißig, die alles ein bisschen können.
Meilenstein Der Helpdesk von Simhaven läuft: acht Tickets, lesen, einordnen, beantworten, eskalieren, schließen, zählen.
Was unterscheidet einen Server, den ein Agent benutzt, von einem, den er nur kennt? Nicht, wie viele Werkzeuge er hat. Es hängt daran, ob sich mit ihnen eine Aufgabe zu Ende bringen lässt.
Ein Server mit dreißig Werkzeugen, die je eine API-Route spiegeln, bleibt eine Sammlung. Ein Agent, der damit arbeiten soll, muss sich die Reihenfolge selbst ausdenken und findet dann an irgendeiner Stelle das eine Werkzeug nicht, mit dem er den Vorgang abschließen könnte. Neun Werkzeuge, die zusammen einen Posteingang leerräumen, sind eine Domäne. Der Unterschied liegt nicht im Umfang.
Die Kulisse
Der Helpdesk von Simhaven. Acht Tickets liegen im Eingang, alle zum selben Produkt, einem Wasserstandssensor namens Tidenwächter TW-2. Vier davon sind Produktfragen, deren Antwort in der Doku steht. Eines meldet eine Störung. Eines beschwert sich über eine doppelte Abbuchung. Eines droht mit Klage. Und eines ist Spam.
Gemischt ist das mit Absicht. Ein Agent, der alles beantwortet, weil Beantworten hilfsbereit wirkt, macht hier zwei Fehler auf einmal: Er antwortet dem Anwalt und er antwortet dem Troll. Die Kulisse stammt aus der Parcours-Aufgabe Der Support-Dienst, wo Mitglieder dagegen antreten.
Der Bestand
Alles liegt im Prozessspeicher. Für ein Tutorial vereinfacht ist das nicht; es ist die richtige Größe für einen Server, der einen abgegrenzten Vorgang bedient. Eine Datenbank darf dazukommen, sobald der Zustand einen Neustart überleben soll.
// 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;
Die acht Tickets stehen als Liste daneben - Absender, Betreff, Text, Eingang - und werden von einer Funktion zu einem frischen Bestand gemacht:
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;
}
Warum eine Funktion und keine Konstante? Weil jede Sitzung ihren eigenen Bestand
bekommen soll. Das ist Kapitel 4, und deshalb entsteht der Bestand hier schon als
Rückgabewert statt als Modul-Variable. Der Parameter runden sieht überflüssig
aus. In Kapitel 6 macht er aus acht Tickets vierhundert und zeigt damit ein
Fehlerbild, das bei acht Stück unsichtbar bleibt.
Neun Werkzeuge
Aufgeteilt ist das nach dem Ablauf im Kopf einer Sachbearbeiterin: erst sehen, was da ist, dann einzelne Vorgänge öffnen, dann handeln, dann prüfen, ob du durch bist. Datenbanktabellen spielen dabei erfahrungsgemäß keine Rolle.
| Werkzeug | wofür |
|---|---|
list_tickets | den Überblick, ohne Volltexte |
get_ticket | einen Vorgang vollständig öffnen |
search_tickets | in Betreff und Text suchen |
read_doc | die Produktdoku - die einzige Faktenquelle |
categorize_ticket | einordnen |
answer_ticket | antworten und auf beantwortet setzen |
escalate_ticket | mit Begründung abgeben |
close_ticket | schließen, auch ohne Antwort |
helpdesk_stats | zählen, was noch offen ist |
read_doc ist der Grenzfall in dieser Liste. Müsste die Produktdoku nach der
reinen Lehre des Protokolls nicht eine Ressource sein statt eines Werkzeugs?
Gelesen wird sie, bewirken tut sie nichts. Nur unterstützen längst nicht alle
Clients Ressourcen, und eine Faktenquelle, die dein Agent nicht erreicht, taugt
nichts.
Also ein Werkzeug, mit klarer Beschreibung. Warum das Protokoll trotzdem
unterscheidet, steht im Baustein.
Zwei Helfer, die alles tragen
Bevor das erste Werkzeug entsteht, zwei Funktionen, die in jedem der neun vorkommen:
// 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 }) }],
};
}
Die beiden unterscheiden sich in isError. Ein Werkzeug, das nicht tun kann,
was es soll, sollte mit fehler() antworten und keine Ausnahme werfen. Eine
geworfene Ausnahme reißt im schlimmsten Fall die Sitzung mit, während der Text
aus fehler() beim Modell landet und dort meistens etwas auslöst, das wie
Nachdenken aussieht.
Der dritte Helfer baut die Zeile einer Liste, und er entscheidet mehr, als er aussieht:
/** 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,
};
}
Das erste echte Werkzeug
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),
});
},
);
Der Bestand kommt als Parameter herein, mit einem frischen als Vorgabe. Die ganze Registrierung ist damit eine Funktion über einem Bestand. Deshalb kann später jede Sitzung ihre eigene Welt bekommen, ohne dass sich bei dir eine einzige Zeile Werkzeugcode ändert.
rest ist eine Kleinigkeit mit großer Wirkung. Ein Modell, das eine Liste mit
25 Einträgen bekommt, kann ohne dieses Feld nicht wissen, ob es fertig ist. Mit
ihm blättert es weiter oder hört auf.
Lesen, handeln, zählen
Die übrigen acht folgen demselben Muster. Zwei davon lohnen einen Blick:
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 hat .min(10). Zehn Zeichen prüfen keine Qualität. Sie fangen den
Aufruf ab, mit dem ein Modell ein Ticket mit „ok“ abhakt.
Und close_ticket verlangt keine Antwort. Darin steckt, was diese Domäne
gemein macht. Spam wird geschlossen und nicht beantwortet. Ein Server, der das
Schließen an eine Antwort koppelt, hätte die Aufgabe für den Agenten schon
mitentschieden.
Was der Server nicht kann
Es gibt kein handle_all_tickets. Kein Werkzeug, das die Kategorie errät. Und
kein answer_from_doc, das sich die Antwort aus der Produktdoku zusammenbaut.
Dieselbe Linie zieht der CRM-Server dieses Repos (mcp/tools/sim_crm.ts). Dort
fehlt absichtlich ein merge_contacts, weil zwei Datensätze zusammenzuführen
genau die Arbeit ist, um die es geht. Ein Werkzeug, das die Aufgabe erledigt,
macht den Agenten überflüssig und den Server unbrauchbar für alles, was auch nur
ein bisschen anders liegt.
Die Grenze verläuft dort, wo etwas aufhört zu können und anfängt zu urteilen. Lesen, anlegen, ändern, löschen, zählen kann der Server. Was wohin gehört, entscheidest du besser nicht für ihn. Das bleibt Sache des Agenten.
Anschließen
Die Startdatei für stdio ist sieben Zeilen lang:
// 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());
Ein Durchlauf über den Prüfstand aus Kapitel 1, mit den echten Ausgaben:
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 }
}
Acht Tickets, ein eskalierter Anwalt, ein geschlossener Troll, fünf offene Vorgänge. Der Server tut etwas.