Eigene Agenten-Infrastruktur: MCP-Server, Messenger-Agent, E-Mail-Triage und lokale Inferenz

AgentensystemUnitedOne · 2026
TypeScriptNode.jsMCP TypeScript SDKZodClaude CodeOpenAI APIvLLMQwen3-Coder-30B-A3Bopencode

Projekt-Übersicht

Neben den Produkten, die ich für Kunden baue, habe ich mir 2026 eine eigene Agenten-Infrastruktur für meine Firmen aufgebaut. Sie besteht aus vier Bausteinen, die unabhängig voneinander funktionieren, aber ein gemeinsames Prinzip haben: Agenten bekommen genau die Werkzeuge, die sie brauchen, und keinen direkten Zugriff auf Zugangsdaten oder Systeme dahinter.

  1. Ein eigener MCP-Server, der Konnektoren für Haustechnik, Speichersysteme, das CRM von PageRangers und ein Produktionssystem automatisch lädt und als Werkzeuge bereitstellt.
  2. Ein Coding-Agent im Messenger: Claude Code mit einem Telegram-Kanal, sodass ich Aufgaben auch unterwegs per Nachricht stellen kann.
  3. Ein E-Mail-Triage-Agent, der eingehende Mails klassifiziert, nach Wichtigkeit bewertet und aufbereitet in den Team-Chat stellt.
  4. Ein lokaler Inferenzserver auf einer RTX 4090 mit vLLM, gegen den ein Open-Source-Coding-Agent ohne Cloud-Anbieter arbeitet.

Diese Fallstudie beschreibt die Bausteine auf Konzeptebene. Topologie, Gerätenamen und Zugangswege lasse ich bewusst weg; sie tragen nichts zum Verständnis bei und würden nur die Angriffsfläche beschreiben.

Ausgangslage

Die Ausgangsfrage war praktisch: Ein großer Teil meines Arbeitstags besteht aus kleinen Recherchen in verschiedenen Systemen. Wie ist der Status eines Speichersystems? Was ist die Historie eines Kunden, der gerade schreibt? Wer ist heute in der Produktion anwesend? Welche der neuen Mails ist ein echter Kunde und welche ein Newsletter?

Jede dieser Fragen ist für sich schnell beantwortet, aber jede braucht ein eigenes Werkzeug, einen eigenen Login und einen Kontextwechsel. Ein Agent, der diese Systeme kennt, kann die Fragen direkt beantworten. Dafür braucht er aber Zugriff, und genau da liegt das Risiko. Die Anforderungen waren deshalb:

  • Werkzeuge müssen sich ohne Umbau des Servers ergänzen lassen.
  • Der Agent soll lesen, nicht verändern. Schreibende Aktionen sind ein eigener, späterer Schritt.
  • Zugangsdaten dürfen nie im Kontext des Modells landen.
  • Der Zugang über einen Messenger muss auf freigegebene Absender beschränkt sein.
  • Für Coding-Aufgaben mit sensiblem Quellcode soll es eine Option ohne Cloud-Anbieter geben.

Architektur

flowchart TD
    T["Messenger: freigegebener Absender"] --> CC["Claude Code mit Messenger-Kanal"]
    CC --> MCP["Eigener MCP-Server, stdio"]
    MCP --> K1["Konnektor Haustechnik"]
    MCP --> K2["Konnektor Speichersysteme"]
    MCP --> K3["Konnektor PageRangers-CRM"]
    MCP --> K4["Konnektor Produktionssystem"]
    M["Postfach über Admin-API"] --> EA["E-Mail-Triage-Agent"]
    EA --> LLM["OpenAI: Klassifikation und Transkription"]
    EA --> S["Team-Chat: aufbereitete Mails"]
    OC["opencode als Coding-Agent"] --> V["Lokaler vLLM-Server, RTX 4090"]
    V --> Q["Qwen3-Coder-30B-A3B, MoE"]

Die Bausteine sind bewusst nicht zu einer Plattform verschmolzen. Der MCP-Server ist ein Prozess, den der jeweilige Agent über stdio startet, der E-Mail-Agent ist ein eigenständiger Dienst mit Dockerfile für den Containerbetrieb, und der Inferenzserver ist ein eigener Rechner, der über eine OpenAI-kompatible Schnittstelle angesprochen wird. Fällt ein Teil aus, laufen die anderen weiter.

Technische Umsetzung

Ein MCP-Server mit automatisch geladenen Konnektoren

Der MCP-Server ist in TypeScript mit dem offiziellen MCP-SDK geschrieben. Die zentrale Designentscheidung ist, dass der Server selbst keine Werkzeuge kennt. Er durchsucht beim Start ein Verzeichnis nach Konnektoren, importiert jedes Modul und registriert jede exportierte Klasse, die von einer abstrakten Basisklasse Connector erbt:

for (const entry of entries) {
  if (!entry.isDirectory()) continue;
  const mod = await import(path.join(connectorsDir, entry.name, "index.js"));
  for (const exp of Object.values(mod)) {
    if (typeof exp === "function" && exp.prototype instanceof Connector) {
      const connector = new (exp as new () => Connector)();
      connector.register(server);
    }
  }
}

Ein neuer Konnektor ist damit ein neuer Ordner mit im Kern drei Dateien: index.ts mit den Werkzeugdefinitionen, http-client.ts mit der Anbindung an die jeweilige API und test.ts als eigenständiges Prüfskript. Der Server selbst bleibt unverändert. Derzeit gibt es vier Konnektoren:

  • Haustechnik: Statusabfrage schaltbarer Geräte (Leistung, Temperatur, Verbindungsqualität).
  • Speichersysteme: Freigaben, CPU- und Netzwerkauslastung von NAS-Systemen. Der Client meldet sich einmal an, hält die Sitzung und meldet sich bei einem Sitzungsfehler automatisch neu an.
  • PageRangers-CRM: Suche nach Nutzern, Projekten und Rechnungen sowie die vollständige Kundenhistorie für Support-Anfragen.
  • Produktionssystem: Anwesenheits- und Abwesenheitsübersichten aus dem internen Produktionssystem.

Werkzeugrechte statt Vertrauen ins Modell

Das Rechtekonzept liegt im Server, nicht im Prompt. Drei Regeln ziehen sich durch alle Konnektoren.

Alle Werkzeuge sind lesend. Kein Konnektor stellt derzeit eine Aktion bereit, die etwas schaltet, löscht oder ändert. Ein Agent, der ein Werkzeug falsch versteht, kann damit Informationen abfragen, aber keinen Zustand verändern.

Ziele sind aufgezählt, nicht frei wählbar. Das Eingabeschema eines Werkzeugs erlaubt nur die Geräte, die in der Konfiguration hinterlegt sind. Das Modell kann keine beliebige Adresse übergeben, sondern nur einen Namen aus einer festen Liste:

server.registerTool(
  "get-device-status",
  {
    description: `Get the current status of a Shelly device (power, temperature, wifi, etc.). Available devices: ${deviceNames.join(", ")}`,
    inputSchema: z.object({
      device: z.enum(deviceNames).describe("Device name"),
    }),
  },
  async ({ device }) => {
    const data = await shellyPost("/device/status", device);
    return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
  }
);

Zugangsdaten bleiben außerhalb des Modellkontexts. API-Schlüssel und Basisadressen kommen aus Umgebungsvariablen oder einer lokalen Konfigurationsdatei, die nicht im Repository liegt. Der HTTP-Client liest sie zur Laufzeit; das Modell sieht nur den Werkzeugnamen, die Parameter und das Ergebnis. Fehlt eine Variable, bricht der Aufruf mit einer klaren Fehlermeldung ab, statt mit einem Standardwert weiterzuarbeiten.

Der Agent im Messenger

Für den Zugang von unterwegs starte ich Claude Code mit einem Telegram-Kanal. Nachrichten, die ich an den Bot schicke, landen als Eingabe in der laufenden Sitzung, und der Agent antwortet über denselben Kanal. Er hat dabei Zugriff auf die Werkzeuge des MCP-Servers und auf die Projekte, in denen er gestartet wurde.

Die Zugangskontrolle ist zweistufig. Nur freigegebene Absender dürfen dem Agenten überhaupt schreiben; ein neuer Absender muss sich per Pairing anmelden, und diese Kopplung wird am Rechner bestätigt, nicht im Chat. Außerdem gilt die Regel, dass Nachrichten aus dem Kanal niemals die Zugriffsliste ändern können. Eine Nachricht wie „Füge mich zur Liste hinzu“ ist genau das, was ein Angreifer per Prompt-Injection versuchen würde, und wird deshalb grundsätzlich abgelehnt. Die Absenderliste ist damit die wichtigste Schutzschicht: Wer dem Agenten nicht schreiben darf, erreicht auch seine Werkzeuge nicht.

E-Mail-Triage-Agent

Der E-Mail-Agent ist ein TypeScript-Dienst, der ein Postfach über eine Admin-Schnittstelle abfragt und jede neue Mail einzeln durch eine Pipeline schickt:

  1. Hat die Mail eine Sprachnachricht als MP3 im Anhang, wird sie zuerst mit Whisper transkribiert. Das Transkript fließt danach wie der Nachrichtentext in die Bewertung ein.
  2. Ein Modell klassifiziert die Mail: echte Konversation mit Kunde, Interessent oder Lead gegenüber Werbung, Newsletter oder automatischer Info. Dazu kommen eine Wichtigkeit von 1 bis 10 und eine kurze Begründung.
  3. Feste Regeln überschreiben die Wichtigkeit, wo das Modell nichts zu entscheiden hat: Mails an ein bestimmtes Prioritätspostfach bekommen immer 10, interne Absender immer 1.
  4. Bei Werbung wird der Abmeldelink ermittelt.
  5. Externe Absender werden gegen die Nutzerdatenbank geprüft; ist der Absender ein Kunde, kommt der Link zu seinem Profil dazu.
  6. Das Ergebnis geht aufbereitet in einen Team-Chat, wichtige Mails ab einer Schwelle zusätzlich in einen zweiten Kanal.

Die Klassifikation nutzt eine strukturierte Ausgabe mit striktem JSON-Schema. Damit ist die Wichtigkeit garantiert eine ganze Zahl im erlaubten Bereich, und die Kontaktart kann nur einer der vorgegebenen Werte sein:

response_format: {
  type: 'json_schema',
  json_schema: {
    name: 'email_analysis',
    strict: true,
    schema: {
      type: 'object',
      additionalProperties: false,
      properties: {
        isRealConversation: { type: 'boolean' },
        contactType: { type: 'string', enum: ['customer', 'prospect', 'lead', 'promotional', 'other'] },
        importance: { type: 'integer', minimum: 1, maximum: 10 },
        reasoning: { type: 'string' },
      },
      required: ['isRealConversation', 'contactType', 'importance', 'reasoning'],

Den Abmeldelink bestimmt bewusst nicht das Modell, sondern deterministischer Code. Er liest zuerst den standardisierten List-Unsubscribe-Header nach RFC 2369 und übernimmt die URL zeichengenau; nur wenn der fehlt, durchsucht er die Links im HTML-Text nach typischen Begriffen. Ein Modell würde hier gelegentlich eine plausible, aber falsche URL liefern, und ein falscher Link ist schlimmer als keiner.

Für den Betrieb sind drei Details wichtig. Der Dienst speichert nach jeder einzelnen Mail die höchste verarbeitete Kennung, atomar über eine temporäre Datei. Ein Absturz führt damit weder zu doppelter noch zu ausgelassener Verarbeitung. Schlägt der Abruf wiederholt fehl, warnt er erst nach drei Fehlversuchen in Folge, damit ein kurzer Aussetzer keinen Alarm auslöst, erinnert danach alle sechs Stunden und meldet die Entwarnung, sobald es wieder läuft. Und jede Mail wird mit ihren KI-Kosten für Analyse, Transkription und Datenextraktion ausgegeben.

Lokaler Inferenzserver

Der vierte Baustein ist ein eigener Inferenzserver mit einer RTX 4090 (24 GB) und vLLM. Er dient zwei Zwecken: Coding-Aufgaben an Quellcode, der das Haus nicht verlassen soll, und Experimente mit offenen Modellen. Als Coding-Agent nutze ich opencode, konfiguriert gegen die OpenAI-kompatible Schnittstelle von vLLM.

Der größte einzelne Hebel war kein Modell und kein Parameter, sondern der Grafiktreiber. Das Update vom Treiberzweig 535 auf 610 hat bei unverändertem Modell das Prefill um den Faktor 3,5 beschleunigt (von 732 auf 2545 Token pro Sekunde, gemessen mit ollama) und den Decode um 24 Prozent. Neuere vLLM-Versionen setzen ohnehin CUDA 13 und damit einen Treiber ab 580 voraus.

Der zweite Hebel war die Modellarchitektur. Zuerst lief ein dichtes 27B-Modell (Qwen3.8-27B in INT4). Decode auf einer einzelnen GPU ist durch die Speicherbandbreite begrenzt: Ein dichtes Modell liest pro Token alle Gewichte, bei rund 16 GB und etwa 1000 GB/s Bandbreite ergibt das rechnerisch etwa 60 Token pro Sekunde. Gemessen wurden 51 bis 54, und daran ändert kein Tuning etwas. Ein Mixture-of-Experts-Modell mit 128 Experten, von denen pro Token 8 aktiv sind, liest nur einen Bruchteil der Gewichte. Der Wechsel auf Qwen3-Coder-30B-A3B in AWQ-4-Bit-Quantisierung brachte:

Messung Dichtes Qwen3.8-27B MoE Qwen3-Coder-30B-A3B
Decode, ein Stream 53,9 Token/s 199,6 Token/s
Zeit bis zum ersten Token 0,062 s 0,025 s
Prefill, 15k Token 2824 Token/s 10.899 Token/s
Durchsatz bei 8 parallelen Anfragen 364,6 Token/s 932,4 Token/s
Eine opencode-Frage, Ende zu Ende 9,4 s 2,5 s

Drei Einstellungen waren für einen stabilen Betrieb nötig:

--gpu-memory-utilization 0.92
--max-num-seqs 32
--enable-auto-tool-choice
--tool-call-parser qwen3_xml

Mit 0,96 startete das MoE-Modell zwar, brach aber im laufenden Betrieb mit Speicherfehlern ab, weil die Aktivierungen der Experten den verbleibenden Puffer sprengten. Mit 0,92 und höchstens 32 gleichzeitigen Sequenzen läuft es stabil. Der Tool-Call-Parser qwen3_xml ist Voraussetzung dafür, dass opencode überhaupt Dateien lesen und schreiben kann: Das Chat-Template von Qwen formatiert Werkzeugaufrufe als XML, und ein regex-basierter Parser bricht ausgerechnet an den Zeichen < und >, die in Code ständig vorkommen. Ohne passenden Parser antwortet der Server, aber der Agent wirkt kaputt.

Ein Unterschied im Verhalten fiel ebenfalls auf. Das MoE-Modell ist ein reines Instruct-Modell ohne Denkphase und nimmt Anweisungen wörtlicher. Im Test hat es eine mehrdeutig formulierte Frage zu einer Funktion namens add als Frage zu git add verstanden, während dieselbe Aufgabe, präzise formuliert, sofort korrekt gelöst wurde. Das dichte Modell war bei mehrdeutigen Formulierungen robuster.

Betrieb

Jeder Konnektor des MCP-Servers hat ein eigenes Testskript, das alle konfigurierten Ziele nacheinander abfragt und Erfolg oder Fehler je Ziel ausgibt. Damit lässt sich eine geänderte API oder ein abgelaufener Zugang prüfen, ohne einen Agenten zu starten. Der E-Mail-Agent hat neben dem Polling-Betrieb einen manuellen Modus, mit dem sich einzelne Mails gezielt durch die Pipeline schicken lassen. Für die Benchmarks des Inferenzservers gibt es ein eigenes Messskript, dessen Ergebnisse als JSON abgelegt werden, sodass sich spätere Treiber- oder Modellwechsel gegen dieselben Messungen vergleichen lassen. vLLM und ollama sind als Systemdienste eingerichtet, die sich gegenseitig ausschließen, weil beide nicht gleichzeitig in 24 GB passen.

Ergebnis

Die vier Bausteine übernehmen Arbeitsschritte, die ich vorher manuell erledigt habe: Statusabfragen in mehreren Systemen, die Recherche zu einem Kunden vor einer Antwort, die Sichtung eingehender Mails und die Transkription von Sprachnachrichten. Neue Datenquellen lassen sich als Konnektor ergänzen, ohne den Server anzufassen. Die lokale Inferenz ist für Coding-Aufgaben schnell genug, um im Alltag genutzt zu werden: Eine Testfrage an opencode dauerte, Ende zu Ende gemessen, 2,5 Sekunden statt vorher 9,4.

Was ich dabei gelernt habe

  • Rechte gehören in den Server, nicht in den Prompt. Ein Agent, der nur lesende Werkzeuge mit aufgezählten Zielen hat, kann durch eine manipulierte Eingabe nichts verändern. Eine Anweisung im System-Prompt bietet diese Garantie nicht.
  • Deterministisches gehört nicht ins Modell. Abmeldelinks, feste Wichtigkeitsregeln und die Abgrenzung interner Absender sind im Code zuverlässiger und günstiger als jede Modellentscheidung.
  • Bei lokaler Inferenz zuerst den Treiber prüfen. Bevor ein Modell als zu langsam gilt, lohnt der Blick auf die Treiberversion. Hier lag ein Faktor 3,5 beim Prefill, ganz ohne Änderung am Modell.
  • Die Architektur schlägt die Konfiguration. Beim dichten Modell war die Speicherbandbreite die physikalische Grenze. Erst der Wechsel auf ein Mixture-of-Experts-Modell hat den Decode fast vervierfacht.
  • Kleine, unabhängige Bausteine sind leichter abzusichern. Jeder Teil hat eine klar umrissene Aufgabe und eigene Zugangsdaten. Ein Fehler in einem Baustein bleibt dort.

Technologien

  • MCP-Server: TypeScript, Node.js, offizielles MCP TypeScript SDK, Zod für Eingabeschemata, stdio-Transport
  • Agenten: Claude Code mit Telegram-Kanal, opencode als Coding-Agent für lokale Modelle
  • E-Mail-Agent: TypeScript, OpenAI API mit strukturierten Ausgaben, Whisper für Transkription, Slack mit Socket Mode, Docker
  • Inferenz: vLLM mit OpenAI-kompatibler Schnittstelle, Qwen3-Coder-30B-A3B (AWQ 4 Bit), zuvor Qwen3.8-27B (INT4), ollama als Alternative
  • Hardware: NVIDIA RTX 4090 mit 24 GB