bifroest/app/demo/mcp.py
2026-10-27 08:34:17 +01:00

449 lines
20 KiB
Python

"""Demo „MCP-Entwicklung“: MCP-Server für ein Service-Desk-Ticketsystem, Leitfaden, Spezifikation in Kürze,
Anbindung an Bifröst, API-Beschreibung, Review-Protokoll und ein Log der Tool-Aufrufe aus der Testumgebung.
Bewusst eingebaut: `close_ticket` ist als readOnlyHint markiert (Bifröst würde es als lesend vorschlagen und
ohne Freigabe zulassen), und `search_tickets` hat keine Obergrenze. Beides hat das Review nach dem Commit beanstandet;
im Code ist es noch offen. Im Log sieht man die Folgen: große, langsame Suchergebnisse.
"""
import csv
import io
import random
from datetime import datetime, timedelta
from app.demo import Demo, day
NAME = "Demo: MCP-Entwicklung"
DESCRIPTION = ("Beispiel-Workspace für die Entwicklung eines MCP-Servers: Repository ticket-mcp (Anbindung des "
"Service-Desk), interner MCP-Leitfaden, Anbindung an Bifröst, API-Beschreibung und ein Log der "
"Tool-Aufrufe aus der Testumgebung.")
EXAMPLES = [
"Welche Tools bietet ticket-mcp, und sind ihre Annotations nach unserem Leitfaden korrekt gesetzt?",
"Passt der Code zu den Beschlüssen aus dem Review von ticket-mcp? Was ist noch offen?",
"Welche Tools waren im Oktober am langsamsten, und wo häufen sich Fehler? Zeig es als Diagramm.",
"Was muss ich beachten, damit Bifröst unseren MCP-Server anbinden kann?",
"Setze die Review-Beschlüsse für close_ticket und search_tickets um und ergänze Tests.",
"Entwirf ein neues Tool list_my_tickets nach unserem Leitfaden und der API-Beschreibung.",
]
# --- Repository -------------------------------------------------------------------------------------------------
README = """# ticket-mcp
MCP-Server für das Service-Desk: Tickets suchen, lesen, kommentieren und schließen. Läuft zentral als
Streamable HTTP (`/mcp`), damit Bifröst und andere Clients ihn erreichen.
## Starten
```bash
uv sync
SERVICEDESK_URL=https://servicedesk.intern/api SERVICEDESK_TOKEN=... uv run ticket-mcp
```
## Tools
| Tool | Zweck |
|---|---|
| `search_tickets` | Tickets nach Text und Status suchen |
| `get_ticket` | Ein Ticket mit Verlauf |
| `add_comment` | Kommentar an ein Ticket |
| `close_ticket` | Ticket schließen |
"""
AGENTS = """# Projektregeln ticket-mcp
- Jedes Tool hat eine Beschreibung, die sagt, wann man es nutzt, und Annotations nach dem MCP-Leitfaden
(`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). Im Zweifel nicht als lesend markieren.
- Parameter mit `Annotated[..., Field(description=...)]`; Grenzen (z. B. `limit`) im Schema, nicht nur im Code.
- Listen höchstens 50 Einträge je Aufruf, weitere Seiten über `cursor`.
- Fehler als `ToolError` mit einer Meldung, die dem Modell sagt, was es anders machen kann.
- Keine Tokens oder personenbezogenen Daten ins Log.
- Zu jedem Tool ein Test in `tests/` mit dem Fake-Client.
"""
PYPROJECT = """[project]
name = "ticket-mcp"
version = "0.4.0"
requires-python = ">=3.12"
dependencies = ["mcp>=1.9", "httpx>=0.27"]
[project.scripts]
ticket-mcp = "ticket_mcp.server:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
"""
CLIENT = '''"""Schlanker Client für die Service-Desk-API (siehe Projektunterlagen: Service-Desk-API)."""
import os
import httpx
class ServiceDesk:
def __init__(self, url: str | None = None, token: str | None = None):
self.http = httpx.Client(base_url=url or os.environ["SERVICEDESK_URL"], timeout=10,
headers={"Authorization": f"Bearer {token or os.environ['SERVICEDESK_TOKEN']}"})
def search(self, query: str, status: str | None, limit: int) -> list[dict]:
params = {"q": query, "limit": limit} | ({"status": status} if status else {})
response = self.http.get("/tickets", params=params)
response.raise_for_status()
return response.json()["items"]
def get(self, ticket_id: str) -> dict | None:
response = self.http.get(f"/tickets/{ticket_id}")
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
def comment(self, ticket_id: str, text: str) -> dict:
response = self.http.post(f"/tickets/{ticket_id}/comments", json={"text": text})
response.raise_for_status()
return response.json()
def close(self, ticket_id: str, resolution: str) -> dict:
response = self.http.post(f"/tickets/{ticket_id}/close", json={"resolution": resolution})
response.raise_for_status()
return response.json()
'''
SERVER_START = '''"""MCP-Server ticket-mcp: Service-Desk-Tickets für KI-Assistenten."""
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
from mcp.types import ToolAnnotations
from pydantic import Field
from ticket_mcp.client import ServiceDesk
mcp = FastMCP("ticket-mcp", instructions=(
"Tickets des Service-Desk. Erst mit search_tickets suchen, dann mit get_ticket lesen. "
"Ticketnummern haben die Form SD-12345."))
desk = ServiceDesk
def client() -> ServiceDesk:
return desk()
@mcp.tool(annotations=ToolAnnotations(title="Tickets suchen", readOnlyHint=True, openWorldHint=True))
def search_tickets(query: Annotated[str, Field(description="Suchbegriffe, z. B. 'VPN Ausfall'")],
status: Annotated[str | None, Field(description="open, in_progress oder closed")] = None,
limit: int = 20) -> list[dict]:
"""Tickets nach Text und Status suchen; liefert Nummer, Titel, Status und letzte Änderung."""
return [{k: t[k] for k in ("id", "title", "status", "updated")} for t in client().search(query, status, limit)]
@mcp.tool(annotations=ToolAnnotations(title="Ticket lesen", readOnlyHint=True, openWorldHint=True))
def get_ticket(ticket_id: Annotated[str, Field(description="Ticketnummer, z. B. SD-12345")]) -> dict:
"""Ein Ticket mit Beschreibung und Verlauf."""
ticket = client().get(ticket_id)
if ticket is None:
raise ToolError(f"Ticket {ticket_id} gibt es nicht. Nummer prüfen oder mit search_tickets suchen.")
return ticket
def main() -> None:
mcp.run(transport="streamable-http")
'''
ADD_COMMENT = '''@mcp.tool(annotations=ToolAnnotations(title="Kommentar schreiben", readOnlyHint=False, destructiveHint=False,
openWorldHint=True))
def add_comment(ticket_id: Annotated[str, Field(description="Ticketnummer")],
text: Annotated[str, Field(description="Kommentar, sichtbar für alle Bearbeitenden", max_length=4000)]
) -> dict:
"""Kommentar an ein Ticket schreiben."""
return client().comment(ticket_id, text)
'''
SERVER_WRITE = SERVER_START.replace("def main() -> None:", ADD_COMMENT + "def main() -> None:")
CLOSE = '''@mcp.tool(annotations=ToolAnnotations(title="Ticket schließen", readOnlyHint=True, openWorldHint=True))
def close_ticket(ticket_id: Annotated[str, Field(description="Ticketnummer")],
resolution: Annotated[str, Field(description="Lösung in einem Satz")]) -> dict:
"""Ticket mit einer Lösung schließen."""
return client().close(ticket_id, resolution)
'''
SERVER = SERVER_WRITE.replace("def main() -> None:", CLOSE + "def main() -> None:")
FAKE = '''"""Fake-Client für Tests: Tickets im Speicher."""
class FakeDesk:
def __init__(self):
self.tickets = {f"SD-{n}": {"id": f"SD-{n}", "title": f"Ticket {n}", "status": "open",
"updated": "2026-10-01", "comments": []} for n in range(1, 121)}
def search(self, query, status, limit):
found = [t for t in self.tickets.values() if not status or t["status"] == status]
return found[:limit]
def get(self, ticket_id):
return self.tickets.get(ticket_id)
def comment(self, ticket_id, text):
self.tickets[ticket_id]["comments"].append(text)
return {"id": ticket_id, "comments": len(self.tickets[ticket_id]["comments"])}
def close(self, ticket_id, resolution):
self.tickets[ticket_id]["status"] = "closed"
return {"id": ticket_id, "status": "closed"}
'''
TESTS = '''import pytest
from mcp.server.fastmcp.exceptions import ToolError
from tests.fake import FakeDesk
from ticket_mcp import server
@pytest.fixture(autouse=True)
def fake(monkeypatch):
desk = FakeDesk()
monkeypatch.setattr(server, "desk", lambda: desk)
return desk
def test_suche_liefert_kurzform():
assert server.search_tickets("Ticket", limit=2) == [
{"id": "SD-1", "title": "Ticket 1", "status": "open", "updated": "2026-10-01"},
{"id": "SD-2", "title": "Ticket 2", "status": "open", "updated": "2026-10-01"}]
def test_unbekanntes_ticket():
with pytest.raises(ToolError, match="gibt es nicht"):
server.get_ticket("SD-999")
def test_kommentar(fake):
server.add_comment("SD-1", "Erledigt?")
assert fake.tickets["SD-1"]["comments"] == ["Erledigt?"]
'''
TESTS_CLOSE = TESTS + '''
def test_schliessen(fake):
server.close_ticket("SD-1", "Neustart hat geholfen")
assert fake.tickets["SD-1"]["status"] == "closed"
'''
DOCKERFILE = """FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir .
EXPOSE 8000
CMD ["ticket-mcp"]
"""
ATTACHMENT = '''"""Anhänge (Entwurf): Datei als Base64 liefern, damit die Dokumenten-Auslese sie lesen kann."""
import base64
from ticket_mcp.server import client, mcp
from mcp.types import ToolAnnotations
@mcp.tool(annotations=ToolAnnotations(title="Anhang lesen", readOnlyHint=True, openWorldHint=True))
def get_attachment(ticket_id: str, attachment_id: str) -> dict:
"""Anhang eines Tickets als Base64 (höchstens 10 MB)."""
data = client().http.get(f"/tickets/{ticket_id}/attachments/{attachment_id}").content
return {"name": attachment_id, "content": base64.b64encode(data).decode()}
'''
COMMITS = [
(20, "ben", "Grundgerüst ticket-mcp mit search_tickets und get_ticket", {
"README.md": README, "pyproject.toml": PYPROJECT, "ticket_mcp/__init__.py": "",
"ticket_mcp/client.py": CLIENT, "ticket_mcp/server.py": SERVER_START}),
(17, "ben", "Tests mit Fake-Client", {
"tests/__init__.py": "", "tests/fake.py": FAKE,
"tests/test_tools.py": TESTS.replace('''
def test_kommentar(fake):
server.add_comment("SD-1", "Erledigt?")
assert fake.tickets["SD-1"]["comments"] == ["Erledigt?"]
''', "")}),
(14, "clara", "add_comment: Kommentare an Tickets", {
"ticket_mcp/server.py": SERVER_WRITE, "tests/test_tools.py": TESTS}),
(9, "anna", "Projektregeln in AGENTS.md", {"AGENTS.md": AGENTS}),
(5, "ben", "close_ticket: Tickets schließen", {
"ticket_mcp/server.py": SERVER, "tests/test_tools.py": TESTS_CLOSE}),
(2, "clara", "Dockerfile für den Betrieb", {"Dockerfile": DOCKERFILE}),
]
BRANCH = ("feature/anhaenge", 1, "ben", "get_attachment: Anhänge als Base64 (Entwurf)",
{"ticket_mcp/attachments.py": ATTACHMENT})
# --- Unterlagen -------------------------------------------------------------------------------------------------
# Review nach dem Commit von close_ticket, vor Dockerfile und Anhängen
REVIEW = day(3)
DOCUMENTS = {
"MCP-Leitfaden intern.md": """# MCP-Leitfaden (intern)
Regeln für MCP-Server, die wir selbst entwickeln und an Bifröst anbinden.
## Transport und Betrieb
- Nur **Streamable HTTP** (ein Endpunkt, z. B. `/mcp`). Bifröst läuft zentral und erreicht keine lokalen
stdio-Server; Dienste laufen deshalb als eigener Container.
- Anmeldung mit `Authorization: Bearer <token>`; der Token gehört in den Secret-Store, nie ins Repository.
- Health-Check unter `/healthz`, strukturierte Logs ohne Tokens und ohne personenbezogene Daten.
## Tools
- **Name:** `verb_objekt` in snake_case (`search_tickets`, `close_ticket`).
- **Beschreibung:** sagt, *wann* man das Tool nutzt und was zurückkommt. Das Modell wählt Tools fast nur nach
der Beschreibung.
- **Parameter:** jeder mit Beschreibung; Grenzen (Länge, Bereich, Aufzählung) im Schema.
- **Annotations** (Pflicht):
- `readOnlyHint: true` nur, wenn das Tool **nichts** verändert, auch nicht im Fremdsystem.
- `destructiveHint: true`, wenn es löscht oder schwer umkehrbar ist.
- `idempotentHint: true`, wenn ein wiederholter Aufruf nichts zusätzlich bewirkt.
- `openWorldHint: true`, wenn es mit einem externen System spricht.
- **Listen:** höchstens 50 Einträge je Aufruf, weitere Seiten über einen `cursor`. Große Antworten kosten Tokens
und werden in Bifröst bei 20.000 Zeichen abgeschnitten.
- **Fehler:** als Tool-Fehler (`isError`) mit einer Meldung, die dem Modell sagt, was es anders machen kann
(„Ticket gibt es nicht, mit search_tickets suchen“), nicht als Stacktrace.
- **Tabellen:** Liefert ein Tool `structuredContent` mit `columns` (Liste von Namen) und `rows` (Liste von
Zeilen), zeigt Bifröst das Ergebnis als Tabelle mit CSV-Download und, wo es passt, als Diagramm.
## Anleitung des Servers
Das Feld `instructions` beschreibt in wenigen Sätzen, wie die Tools zusammen zu nutzen sind. Bifröst gibt es
dem Modell mit, sobald Tools des Servers angeboten werden. Keine Regeln hineinschreiben, die Bifröst-Regeln
aushebeln sollen; das Modell behandelt die Anleitung als nachrangig.
""",
"MCP-Spezifikation Kurzfassung.md": """# MCP in Kürze
Das Model Context Protocol verbindet KI-Anwendungen (Clients) mit Diensten (Servern) über JSON-RPC 2.0.
## Bausteine eines Servers
- **Tools:** Funktionen, die das Modell aufruft (`tools/list`, `tools/call`). Jedes Tool hat Name,
Beschreibung, `inputSchema` (JSON Schema) und optional `annotations` und ein `outputSchema`.
- **Resources:** lesbare Inhalte mit URI (`resources/list`, `resources/read`), z. B. Dateien oder Datensätze.
- **Prompts:** vorbereitete Vorlagen, die Nutzer auswählen (`prompts/list`, `prompts/get`).
## Ablauf
1. `initialize`: Client und Server tauschen Protokollversion und Fähigkeiten (capabilities) aus; der Server
kann `instructions` mitgeben.
2. `notifications/initialized`, danach normale Anfragen.
3. Ein Tool-Ergebnis enthält `content` (Text, Bild, eingebettete Ressource …), optional `structuredContent`
und `isError: true` bei einem Fehler, den das Modell sehen soll.
## Tool-Annotations
`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Es sind **Hinweise**: Ein
Client darf ihnen nicht blind vertrauen, wenn der Server nicht vertrauenswürdig ist.
## Transporte
- **stdio:** der Client startet den Server als Prozess (lokal).
- **Streamable HTTP:** ein HTTP-Endpunkt für POST (Anfragen) und optional GET (Server-Nachrichten per SSE);
eine Sitzung wird über den Header `Mcp-Session-Id` gehalten.
""",
"Anbindung an Bifröst.md": """# Einen MCP-Server an Bifröst anbinden
1. **Erreichbarkeit:** Der Host muss in der Freigabeliste des Betriebs stehen (`AIW_MCP_ALLOWED_HOSTS`).
2. **Registrieren:** Ein Plattform-Admin legt den Server unter *Administration → MCP-Server* an (URL, Token)
und klickt *Tools abrufen*. Alternativ legt ein Workspace-Admin eine Verbindung im Workspace an; zu einer
neuen Adresse wartet sie auf Freigabe durch einen Plattform-Admin.
3. **Einstufen:** Jedes Tool bekommt eine Risikoklasse. Bifröst schlägt sie aus den Annotations vor:
- `readOnlyHint: true` → `read` (standardmäßig ohne Freigabe erlaubt),
- `destructiveHint: true` → `destructive` (für die KI gesperrt),
- sonst `write_external` (standardmäßig nur mit Freigabe durch einen Menschen).
Der Vorschlag ist nur ein Vorschlag; entschieden wird von Hand. **Ein falsches `readOnlyHint` führt also
leicht dazu, dass ein schreibendes Tool ohne Freigabe läuft.**
4. **Freischalten:** Workspace-Admins schalten den Server für ihren Workspace frei.
5. **Änderungen:** Ändern sich Name, Beschreibung oder Schema eines Tools, erkennt Bifröst das am
Fingerabdruck; das Tool bleibt gesperrt, bis es neu eingestuft ist.
Grenzen: Zeitlimit je Aufruf 30 Sekunden (`AIW_MCP_TIMEOUT_SECONDS`), Ausgabe höchstens 20.000 Zeichen.
Tool-Ergebnisse gehen als Fremdinhalt markiert an das Modell.
""",
"Service-Desk-API.md": """# Service-Desk-API (Auszug)
Basis-URL `https://servicedesk.intern/api`, Anmeldung mit Bearer-Token eines technischen Kontos.
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/tickets?q=&status=&assignee=&limit=&cursor=` | Suche; `limit` höchstens 100, Antwort mit `items` und `next_cursor` |
| GET | `/tickets/{id}` | Ticket mit Beschreibung, Verlauf, Anhängen (nur Metadaten) |
| GET | `/tickets/{id}/attachments/{aid}` | Anhang als Datei |
| POST | `/tickets/{id}/comments` | Kommentar (`{"text": ...}`), 4000 Zeichen |
| POST | `/tickets/{id}/close` | Schließen (`{"resolution": ...}`); erneutes Schließen ändert nichts |
| GET | `/me/tickets?status=` | Tickets, die dem technischen Konto bzw. per `X-On-Behalf-Of` einer Person zugewiesen sind |
Status: `open`, `in_progress`, `closed`. Ticketnummern: `SD-` und fünf Ziffern.
Fehler: 404 (nicht gefunden), 409 (Ticket bereits geschlossen beim Kommentieren), 429 (zu viele Anfragen,
`Retry-After` beachten).
""",
f"Protokolle/{REVIEW:%Y-%m-%d} Review ticket-mcp.md": f"""# Review ticket-mcp, {REVIEW:%d.%m.%Y}
Teilnehmende: Entwicklung, Betrieb, Plattform-Team Bifröst
## Ergebnisse
1. **close_ticket** verändert das Fremdsystem und darf nicht als lesend markiert sein:
`readOnlyHint: false`, `destructiveHint: false` (Tickets lassen sich wieder öffnen), `idempotentHint: true`.
In Bifröst wird es damit `write_external` und braucht eine Freigabe.
2. **search_tickets:** `limit` im Schema auf 1 bis 50 begrenzen (Standard 20) und `cursor` für weitere Seiten
ergänzen. In der Testumgebung kamen Antworten mit über 200 KB vor.
3. **get_ticket:** Fehlertext ist gut; so beibehalten.
4. Neues Tool **list_my_tickets** für „meine offenen Tickets“ über `/me/tickets`, lesend.
## Offen
- Anhänge (geplant als `feature/anhaenge`): Größe begrenzen, Inhalt über die Dokumenten-Auslese statt Base64
ans Modell.
""",
}
# --- Log der Tool-Aufrufe ---------------------------------------------------------------------------------------
TOOLS = { # Tool: (Anteil, mittlere Dauer ms, Fehlerquote, mittlere Antwort KB)
"search_tickets": (45, 900, 0.03, 40), "get_ticket": (35, 180, 0.08, 6), "add_comment": (12, 250, 0.02, 1),
"close_ticket": (8, 300, 0.05, 1)}
ERRORS = {"search_tickets": ["Timeout nach 30 s", "429 Too Many Requests"],
"get_ticket": ["Ticket gibt es nicht", "Ticket gibt es nicht", "429 Too Many Requests"],
"add_comment": ["409 Ticket bereits geschlossen"], "close_ticket": ["409 Ticket bereits geschlossen"]}
CLIENTS = ["bifroest", "bifroest", "bifroest", "test-skript"]
def calls_csv(rows: int = 2000) -> bytes:
"""Tool-Aufrufe der Testumgebung im Oktober 2026, reproduzierbar. Große Suchen (limit > 50) sind langsam."""
rng = random.Random(1008)
out = io.StringIO()
writer = csv.writer(out)
writer.writerow(["zeit", "tool", "client", "limit", "dauer_ms", "status", "fehler", "antwort_kb"])
start = datetime(2026, 10, 1, 7, 0)
for _ in range(rows):
when = start + timedelta(minutes=rng.randrange(0, 24 * 60 * 24))
tool = rng.choices(list(TOOLS), weights=[t[0] for t in TOOLS.values()])[0]
_, duration, error_rate, size = TOOLS[tool]
limit = rng.choice([10, 20, 20, 50, 100, 200]) if tool == "search_tickets" else ""
factor = (limit / 20) if limit else 1
ms = int(rng.lognormvariate(0, 0.4) * duration * max(1, factor ** 0.8))
kb = round(rng.lognormvariate(0, 0.3) * size * factor, 1)
status, error = "ok", ""
if ms > 30000 or (limit and limit >= 200 and rng.random() < 0.15):
status, error, ms = "timeout", "Timeout nach 30 s", 30000
elif rng.random() < error_rate:
status, error = "error", rng.choice(ERRORS[tool])
writer.writerow([when.isoformat(timespec="minutes"), tool, rng.choice(CLIENTS), limit, ms, status, error,
kb])
return out.getvalue().encode()
DEMO = Demo(key="mcp", name=NAME, description=DESCRIPTION, examples=EXAMPLES, repo="ticket-mcp", commits=COMMITS,
branch=BRANCH, upload=("tool_aufrufe_2026_10.csv", calls_csv), folder="MCP-Unterlagen",
documents=DOCUMENTS)