40 min

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.

Werkzeugwofür
list_ticketsden Überblick, ohne Volltexte
get_ticketeinen Vorgang vollständig öffnen
search_ticketsin Betreff und Text suchen
read_docdie Produktdoku - die einzige Faktenquelle
categorize_ticketeinordnen
answer_ticketantworten und auf beantwortet setzen
escalate_ticketmit Begründung abgeben
close_ticketschließen, auch ohne Antwort
helpdesk_statszä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.