Skip to main content
Glama
LucaT02

betrieb-mcp

by LucaT02
README.md
# betrieb-mcp

MCP-Server, der einem KI-Agenten den Zugriff auf den Betriebsalltag eines
Gebäudedienstleisters gibt: Kundenakte, Terminplanung, Angebotskalkulation.

Der eigentliche Gegenstand dieses Repositories ist nicht die Fachlogik, sondern
**die Frage, wie ein Agent schreiben darf.** Lesen ist frei. Jeder schreibende
Vorgang läuft zweistufig über eine Freigabe-Schranke.

---

## Warum zweistufig

Ein Agent, der Termine buchen und Angebote anlegen kann, ist nützlich. Ein Agent,
der das *unbeaufsichtigt* tut, ist ein Haftungsrisiko: Ein falsch verstandener
Satz am Telefon, und ein Monteur steht am falschen Tag beim falschen Kunden.

Die übliche Antwort darauf ist ein Bestätigungsdialog im Frontend. Der reicht
nicht, weil er außerhalb des Systems liegt — der Agent kann ihn umgehen, sobald
er über eine andere Oberfläche angesprochen wird. Hier sitzt die Schranke
deshalb **im Server**:

1. `betrieb_prepare_*` rechnet, prüft auf Konflikte, erzeugt eine **Vorschau in
   Klartext** und ein **Freigabe-Token**. Geschrieben wird nichts.
2. Ein Mensch liest die Vorschau und bestätigt.
3. `betrieb_commit_*` löst das Token ein und schreibt.

Das Token ist an genau eine Nutzlast gebunden, fünf Minuten gültig, nicht
zwischen Aktionen übertragbar und nach einmaliger Verwendung verbraucht. Ein
Agent, der die Vorschau überspringen will, hat kein Token — und damit keinen
Schreibzugriff. Die Regel ist nicht Konvention, sondern Mechanik.

**Zusätzlich wird der Terminkonflikt unmittelbar vor dem Schreiben erneut
geprüft.** Zwischen Vorschau und Bestätigung können Minuten liegen; in denen
kann jemand anders denselben Slot belegt haben. Ohne diese zweite Prüfung wäre
die Schranke ein Papiertiger — sie würde nur den Zustand von vorhin bestätigen.

---

## Werkzeuge

| Werkzeug | Art | Zweck |
|---|---|---|
| `betrieb_suche_kunden` | lesend | Suche über Name, Ort, Kundennummer; mit Blättern |
| `betrieb_hole_kunde` | lesend | Stammdaten samt Terminen und Angeboten |
| `betrieb_liste_termine` | lesend | Termine im Zeitraum, optional je Monteur |
| `betrieb_freie_slots` | lesend | Freie Zeitfenster eines Monteurs an einem Tag |
| `betrieb_preisliste` | lesend | Leistungskatalog mit Einheiten und Nettopreisen |
| `betrieb_kalkuliere` | lesend | Positionen durchrechnen, ohne etwas anzulegen |
| `betrieb_prepare_angebot` | Vorschau | Kalkuliert und gibt Freigabe-Token aus |
| `betrieb_prepare_termin` | Vorschau | Prüft Konflikte und gibt Freigabe-Token aus |
| `betrieb_commit_angebot` | **schreibend** | Legt das Angebot an — nur mit Token |
| `betrieb_commit_termin` | **schreibend** | Bucht den Termin — nur mit Token |

`betrieb_freie_slots` ist bewusst ein eigenes Werkzeug und kein Nebenprodukt der
Terminliste: Ein Agent, der buchen soll, braucht die Lücken, nicht die Belegung.
Aus einer Liste von Terminen die freien Fenster abzuleiten, ist eine
Rechenaufgabe, die im Server besser aufgehoben ist als im Modell.

---

## Ablauf in echt

Ungekürzte Ausgaben eines Laufs gegen die Demo-Datenbank.

**Freie Fenster suchen — Kaya hat vormittags und nachmittags Termine:**

```json
$ betrieb_freie_slots  datum=2026-09-01  dauer_minuten=90  monteur=Kaya
{
  "datum": "2026-09-01",
  "monteur": "Kaya",
  "dauer_minuten": 90,
  "freie_fenster": [
    { "von": "11:00", "bis": "13:00" },
    { "von": "15:30", "bis": "17:00" }
  ]
}
```

**Einen belegten Slot buchen wollen — der Fehler nennt den Grund und den
nächsten Schritt:**

```json
$ betrieb_prepare_termin  kunde_id=K-1002  datum=2026-09-01  von=09:00 ...
{
  "fehler": "Kaya ist am 2026-09-01 von 08:00 bis 11:00 bereits gebunden
             (Treppenhausreinigung, 3 Aufgänge). Nutze 'betrieb_freie_slots',
             um ein passendes Fenster zu finden."
}
```

**Angebot vorbereiten — Vorschau für den Menschen, Token für den Agenten:**

```
$ betrieb_prepare_angebot  kunde_id=K-1001  positionen=[...]

Angebot für Hausverwaltung Keller GmbH (K-1001), Pforzheim

  420 m²           grundreinigung_qm             1008.00 EUR
  24 Element       fensterreinigung_element       165.60 EUR
  1 pauschal       anfahrt                         45.00 EUR

  Netto                                       1218.60 EUR
  zzgl. 19 % MwSt.                             231.53 EUR
  Brutto                                      1450.13 EUR

freigabe_token: frg_uG6AsbNllcRtJnaS
hinweis: Es wurde nichts gespeichert. Lege die Vorschau dem Menschen vor und
         rufe erst nach ausdrücklicher Bestätigung 'betrieb_commit_angebot' auf.
```

**Bestätigen — und ein zweites Mal bestätigen:**

```json
$ betrieb_commit_angebot  freigabe_token=frg_uG6AsbNllcRtJnaS
{ "angebot_id": "ANG-2026-5044", "status": "entwurf", "brutto": "1450.13" }

$ betrieb_commit_angebot  freigabe_token=frg_uG6AsbNllcRtJnaS
{ "fehler": "Unbekanntes oder bereits eingelöstes Freigabe-Token. ..." }
```

Der zweite Aufruf ist der interessante: Ein wiederholter Werkzeugaufruf — durch
Neuversuch, Kontextverlust oder schlicht Verwirrung des Modells — erzeugt kein
zweites Angebot.

---

## Entscheidungen, die bewusst so getroffen sind

**Fachlogik ohne MCP-Abhängigkeit.** `core.py` kennt weder Protokoll noch Tools.
`server.py` ist eine dünne Schicht aus Schemas, Freigaben und Fehlerübersetzung.
Deshalb laufen die Tests ohne Server, und dieselbe Logik wäre hinter einer REST-
API oder einem CLI wiederverwendbar.

**Geld als `Decimal`, nie als `float`.** `0.1 + 0.2` ist in einem Angebot kein
akademisches Problem. Gerundet wird kaufmännisch (`ROUND_HALF_UP`) und nur an
einer Stelle.

**Fehler sind Antworten, keine Exceptions.** Jedes Werkzeug gibt bei fachlichen
Problemen `{"fehler": "..."}` zurück, und zwar mit dem konkreten nächsten
Schritt („Nutze `betrieb_freie_slots`…"). Ein Stacktrace beendet den Agentenlauf,
ohne dass jemand klüger geworden ist.

**Annotationen ehrlich gesetzt.** `prepare` ist `readOnlyHint: true`, aber
`idempotentHint: false` — es schreibt nichts, erzeugt aber bei jedem Aufruf ein
neues Token. `commit` ist `readOnlyHint: false`. Wer diese Hinweise falsch setzt,
nimmt Clients die Möglichkeit, sinnvoll nachzufragen.

**SQLite ohne ORM.** Das Repository soll ohne Fremdabhängigkeit und ohne
Migrationsschritt starten. Für fünf Tabellen ist eine ORM-Schicht Ballast.

---

## Start

```bash
git clone https://github.com/LucaT02/betrieb-mcp
cd betrieb-mcp

python3 -m unittest discover -s tests     # 22 Tests, keine Abhängigkeiten nötig

pip install -e .                          # nur für den Serverbetrieb
python -m betrieb_mcp.seed                # Demo-Datenbank anlegen
python -m betrieb_mcp.server              # Server über stdio starten
```

In Claude Desktop oder Claude Code eintragen:

```json
{
  "mcpServers": {
    "betrieb": {
      "command": "python",
      "args": ["-m", "betrieb_mcp.server"],
      "env": { "BETRIEB_DB": "/absoluter/pfad/betrieb.db" }
    }
  }
}
```

---

## Tests

22 Tests, Schwerpunkt auf den Stellen, an denen es weh tut:

- **Kalkulation** — Summen, kaufmännische Rundung, unbekannte Leistung, Menge 0
- **Freie Fenster** — Lücke zwischen Terminen, Randfenster vor Feierabend, kein Platz
- **Freigabe-Schranke** — Vorschau schreibt nichts; Token schreibt genau einmal;
  Token nicht zwischen Aktionen übertragbar; abgelaufenes und erfundenes Token
- **Terminkonflikt** — Überlappung, Ende nach Feierabend, und der Fall, für den
  die zweite Prüfung existiert: ein Slot, der zwischen Vorschau und Bestätigung
  belegt wurde

---

## Was fehlt

Ehrlich benannt, statt so zu tun, als wäre das produktionsreif:

- **Kein Mehrbenutzerbetrieb.** Der Freigabespeicher liegt im Prozess. Für
  mehrere Server-Instanzen müsste er nach Redis oder in die Datenbank wandern.
- **Keine Authentifizierung.** stdio-Transport, ein Betrieb, ein Prozess. Für
  einen entfernten Server bräuchte es Streamable HTTP samt Auth.
- **Kein Audit-Log.** Wer wann was freigegeben hat, wird nicht protokolliert —
  für einen echten Einsatz die erste Ergänzung, die ich bauen würde.
- **Die Preisliste ist hart verdrahtet.** In einer echten Installation käme sie
  aus der Warenwirtschaft.

---

## Lizenz

MIT