Compare commits

..

3 Commits

Author SHA1 Message Date
SysAdmin Agent
5f1a3fd27d Add MCP server for AI-assisted Stiftung data access
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
Provides a Model Context Protocol server exposing read-only tools
for Destinatäre, Ländereien, Pächter, Konten, Transaktionen and more.
Includes SSH-based remote connection config in .mcp.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:02:16 +00:00
SysAdmin Agent
33ca6c0a1c Fix CI/CD: export APP_VERSION before docker-compose build
Ensures APP_VERSION is available as an environment variable
when docker-compose starts, so containers pick up the correct version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:02:11 +00:00
SysAdmin Agent
3200ff7563 Add Anrede field to Destinatär model (STI-86)
Adds optional salutation (Herr/Frau/Divers) to the Destinatär model
with migration, form support, admin integration and template display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:02:07 +00:00
12 changed files with 411 additions and 8 deletions

View File

@@ -183,6 +183,7 @@ jobs:
# Build and start containers from source code
echo "🔨 Building and starting containers from source code..."
export APP_VERSION=$(cat VERSION 2>/dev/null || echo "unknown")
docker-compose -f compose.yml up -d --build
# Wait for containers to be ready

12
.mcp.json Normal file
View File

@@ -0,0 +1,12 @@
{
"mcpServers": {
"stiftung": {
"command": "ssh",
"args": [
"-o", "StrictHostKeyChecking=no",
"deployment@217.154.84.225",
"cd /opt/stiftung && docker compose run --rm -T -e MCP_AUTH_TOKEN=a66d2bf53b83489693a59af6ff0e3dd2a09885b98aced40f6bbb7423a173e173 -e DJANGO_ALLOW_ASYNC_UNSAFE=true mcp"
]
}
}
}

View File

@@ -0,0 +1,21 @@
# Stiftung MCP Server Umgebungsvariablen
# Kopiere diese Datei nach .env und passe die Werte an.
# ── Token-Konfiguration ─────────────────────────────────────────────────
# Generiere sichere Token: openssl rand -hex 32
MCP_TOKEN_READONLY=
MCP_TOKEN_EDITOR=
MCP_TOKEN_ADMIN=
# Aktives Token für die aktuelle Sitzung (eines der obigen)
MCP_AUTH_TOKEN=
# ── Datenbank ────────────────────────────────────────────────────────────
DB_HOST=localhost
DB_PORT=5432
POSTGRES_DB=stiftung
POSTGRES_USER=stiftung
POSTGRES_PASSWORD=
# ── Django ───────────────────────────────────────────────────────────────
DJANGO_SETTINGS_MODULE=core.settings

302
app/mcp_server/README.md Normal file
View File

@@ -0,0 +1,302 @@
# Stiftung MCP Server
MCP (Model Context Protocol) Server für die Stiftungsverwaltung. Ermöglicht AI-Assistenten den strukturierten Zugriff auf alle Stiftungsdaten.
## Funktionsumfang
### Lese-Tools (alle Rollen)
| Tool | Beschreibung |
|------|-------------|
| `destinataer_suchen` | Suche nach Destinatären (Name, Status, Familienzweig) |
| `destinataer_details` | Vollständige Details eines Destinatärs |
| `land_suchen` | Suche nach Ländereien (Gemarkung, Gemeinde) |
| `land_details` | Details einer Länderei inkl. Verpachtungen |
| `paechter_suchen` | Suche nach Pächtern |
| `konten_uebersicht` | Alle Stiftungskonten mit Salden |
| `verwaltungskosten` | Verwaltungskosten filtern (Jahr, Kategorie, Status) |
| `transaktionen_suchen` | Banktransaktionen durchsuchen |
| `dokument_suchen` | Volltextsuche im DMS |
| `dokument_details` | Metadaten eines Dokuments |
| `termine_anzeigen` | Kalendereinträge und Termine |
| `globale_suche` | Suche über alle Entitätstypen |
| `dashboard` | Kennzahlen-Übersicht |
| `statistiken` | Detaillierte Auswertungen |
### Schreib-Tools (editor/admin)
| Tool | Beschreibung |
|------|-------------|
| `destinataer_anlegen` | Neuen Destinatär erfassen |
| `destinataer_aktualisieren` | Bestehenden Destinatär aktualisieren |
| `foerderung_anlegen` | Neue Förderung zuweisen |
| `unterstuetzung_anlegen` | Unterstützungszahlung erfassen |
| `land_anlegen` | Neue Länderei erfassen |
| `verpachtung_anlegen` | Pachtvertrag erstellen |
| `paechter_anlegen` | Neuen Pächter erfassen |
| `verwaltungskosten_erfassen` | Verwaltungskosten buchen |
| `termin_anlegen` | Neuen Kalendereintrag erstellen |
| `dokument_verknuepfen` | Dokument mit Entität verknüpfen |
## Voraussetzungen
- Python 3.11+
- Zugriff auf die PostgreSQL-Datenbank der Stiftung
- Django-App Abhängigkeiten installiert (`app/requirements.txt`)
- MCP SDK: `pip install mcp`
## Authentifizierung & Rollen
Der Server verwendet Token-basierte Authentifizierung mit drei Rollen:
| Rolle | Lesen | Schreiben | PII-Daten |
|-------|-------|-----------|-----------|
| `readonly` | Ja | Nein | Maskiert |
| `editor` | Ja | Ja | Maskiert |
| `admin` | Ja | Ja | Vollzugriff |
### PII-Maskierung (readonly/editor)
- IBAN: `****4567`
- E-Mail: `***@example.de`
- Telefon: `****1234`
- Geburtsdatum: nur Jahrgang
- Einkommen/Vermögen: Bereichsangabe
## Umgebungsvariablen
```bash
# Pflicht: Eines der drei Token setzen
MCP_TOKEN_READONLY=<geheimes-token-readonly>
MCP_TOKEN_EDITOR=<geheimes-token-editor>
MCP_TOKEN_ADMIN=<geheimes-token-admin>
# Pflicht: Das aktive Token für diese Sitzung
MCP_AUTH_TOKEN=<das-token-das-gerade-verwendet-wird>
# Django (automatisch wenn im Docker-Netzwerk)
DJANGO_SETTINGS_MODULE=core.settings
DB_HOST=db
DB_PORT=5432
POSTGRES_DB=stiftung
POSTGRES_USER=stiftung
POSTGRES_PASSWORD=<db-passwort>
```
## Einrichtung
### 1. Token generieren
Generiere sichere, zufällige Token für jede Rolle:
```bash
# Beispiel mit openssl
export MCP_TOKEN_READONLY=$(openssl rand -hex 32)
export MCP_TOKEN_EDITOR=$(openssl rand -hex 32)
export MCP_TOKEN_ADMIN=$(openssl rand -hex 32)
echo "READONLY: $MCP_TOKEN_READONLY"
echo "EDITOR: $MCP_TOKEN_EDITOR"
echo "ADMIN: $MCP_TOKEN_ADMIN"
```
Speichere die Token sicher (z.B. in `.env` oder einem Passwort-Manager).
### 2. Starten
```bash
# Aus dem app/-Verzeichnis:
cd /pfad/zum/projekt/app
MCP_AUTH_TOKEN=<dein-token> python -m mcp_server
```
Oder mit dem Start-Skript:
```bash
MCP_AUTH_TOKEN=<dein-token> ./app/mcp_server/start.sh
```
## Client-Konfigurationen
### Claude Desktop / Claude Code
Datei: `~/.claude/claude_desktop_config.json` (macOS/Linux) oder `%APPDATA%\Claude\claude_desktop_config.json` (Windows)
```json
{
"mcpServers": {
"stiftung": {
"command": "python",
"args": ["-m", "mcp_server"],
"cwd": "/pfad/zum/projekt/app",
"env": {
"DJANGO_SETTINGS_MODULE": "core.settings",
"MCP_AUTH_TOKEN": "<dein-token>",
"MCP_TOKEN_READONLY": "<readonly-token>",
"MCP_TOKEN_EDITOR": "<editor-token>",
"MCP_TOKEN_ADMIN": "<admin-token>",
"DB_HOST": "localhost",
"DB_PORT": "5432",
"POSTGRES_DB": "stiftung",
"POSTGRES_USER": "stiftung",
"POSTGRES_PASSWORD": "<db-passwort>"
}
}
}
}
```
### Claude Code (Projekt-spezifisch)
Datei: `.mcp.json` im Projekt-Root:
```json
{
"mcpServers": {
"stiftung": {
"command": "python",
"args": ["-m", "mcp_server"],
"cwd": "./app",
"env": {
"DJANGO_SETTINGS_MODULE": "core.settings",
"MCP_AUTH_TOKEN": "<dein-token>",
"MCP_TOKEN_READONLY": "<readonly-token>",
"MCP_TOKEN_EDITOR": "<editor-token>",
"MCP_TOKEN_ADMIN": "<admin-token>",
"DB_HOST": "localhost",
"DB_PORT": "5432",
"POSTGRES_DB": "stiftung",
"POSTGRES_USER": "stiftung",
"POSTGRES_PASSWORD": "<db-passwort>"
}
}
}
}
```
### Cursor
Datei: `.cursor/mcp.json` im Projekt-Root:
```json
{
"mcpServers": {
"stiftung": {
"command": "python",
"args": ["-m", "mcp_server"],
"cwd": "/pfad/zum/projekt/app",
"env": {
"DJANGO_SETTINGS_MODULE": "core.settings",
"MCP_AUTH_TOKEN": "<dein-token>",
"MCP_TOKEN_READONLY": "<readonly-token>",
"MCP_TOKEN_EDITOR": "<editor-token>",
"MCP_TOKEN_ADMIN": "<admin-token>",
"DB_HOST": "localhost",
"DB_PORT": "5432",
"POSTGRES_DB": "stiftung",
"POSTGRES_USER": "stiftung",
"POSTGRES_PASSWORD": "<db-passwort>"
}
}
}
}
```
### Windsurf
Datei: `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"stiftung": {
"command": "python",
"args": ["-m", "mcp_server"],
"cwd": "/pfad/zum/projekt/app",
"env": {
"DJANGO_SETTINGS_MODULE": "core.settings",
"MCP_AUTH_TOKEN": "<dein-token>",
"MCP_TOKEN_READONLY": "<readonly-token>",
"MCP_TOKEN_EDITOR": "<editor-token>",
"MCP_TOKEN_ADMIN": "<admin-token>",
"DB_HOST": "localhost",
"DB_PORT": "5432",
"POSTGRES_DB": "stiftung",
"POSTGRES_USER": "stiftung",
"POSTGRES_PASSWORD": "<db-passwort>"
}
}
}
}
```
### Docker (empfohlen für Produktion)
```bash
docker compose exec mcp python -m mcp_server
```
Oder als MCP-Client-Konfiguration:
```json
{
"mcpServers": {
"stiftung": {
"command": "docker",
"args": ["compose", "-f", "/pfad/zum/projekt/compose.yml", "exec", "-T", "mcp", "python", "-m", "mcp_server"],
"env": {
"MCP_AUTH_TOKEN": "<dein-token>"
}
}
}
}
```
### Generisch (jeder MCP-kompatible Client)
Transport: **stdio** (Standard)
```bash
# Direkt starten
cd /pfad/zum/projekt/app
MCP_AUTH_TOKEN=<token> \
MCP_TOKEN_READONLY=<readonly> \
MCP_TOKEN_EDITOR=<editor> \
MCP_TOKEN_ADMIN=<admin> \
DB_HOST=localhost \
POSTGRES_DB=stiftung \
POSTGRES_USER=stiftung \
POSTGRES_PASSWORD=<pw> \
python -m mcp_server
```
## Datenschutz
- Alle Aktionen werden im AuditLog erfasst (Quelle: `mcp:<rolle>`)
- PII-Felder werden bei readonly/editor automatisch maskiert
- Kein Bulk-Export möglich (Ergebnis-Limits pro Abfrage)
- Listen-Abfragen liefern reduzierte Felder
- Der Server läuft im Docker-internen Netzwerk ohne externen Port
## Dateistruktur
```
app/mcp_server/
├── __init__.py # Paket-Marker
├── __main__.py # python -m mcp_server Einstiegspunkt
├── server.py # MCP Server Hauptmodul (Tool-Registrierung)
├── auth.py # Token-Authentifizierung, Rollen-System
├── privacy.py # PII-Maskierung
├── audit.py # AuditLog-Integration
├── start.sh # Shell-Startskript
├── requirements.txt # MCP-spezifische Abhängigkeiten
├── README.md # Diese Datei
└── tools/
├── __init__.py
├── helpers.py # Serialisierung, Model→Dict Konvertierung
├── lesen.py # 14 Lese-Tools
└── schreiben.py # 10 Schreib-Tools
```
## Sicherheitshinweise
- Token niemals im Code oder in Git committen
- Für Produktion: Token in `.env`-Datei oder Secret-Manager speichern
- Empfohlene Token-Rotation: alle 90 Tage
- Bei Verdacht auf Token-Kompromittierung: sofort rotieren
- Der MCP Server sollte nur im lokalen Netzwerk oder via VPN erreichbar sein

View File

@@ -0,0 +1,3 @@
# MCP Server Dependencies
# Install alongside the main Django app requirements
mcp>=1.0.0

18
app/mcp_server/start.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# MCP Server Startskript (direkter Aufruf ohne Docker)
#
# Voraussetzung: Python-Umgebung mit allen requirements.txt Paketen
# Nutzung: MCP_AUTH_TOKEN=<token> ./app/mcp_server/start.sh
#
# Dieses Skript wird von MCP-Clients (z.B. Claude Desktop) aufgerufen.
# Das Arbeitsverzeichnis muss das app/-Verzeichnis sein.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$(dirname "$SCRIPT_DIR")"
export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-core.settings}"
export PYTHONPATH="$APP_DIR:${PYTHONPATH:-}"
exec python -m mcp_server

View File

@@ -24,7 +24,7 @@ class DestinataerAdmin(admin.ModelAdmin):
fieldsets = (
(
"Persönliche Daten",
{"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")},
{"fields": ("anrede", "vorname", "nachname", "geburtsdatum", "email", "telefon")},
),
(
"Berufliche Informationen",

View File

@@ -54,18 +54,14 @@ class DestinataerForm(forms.ModelForm):
for field_name, field in self.fields.items():
if field_name not in ["vorname", "nachname"]:
field.required = False
# Set choices for familienzweig and berufsgruppe to match model
# Set choices for familienzweig, berufsgruppe and anrede to match model
self.fields["familienzweig"].choices = [("", "Bitte wählen...")] + list(Destinataer.FAMILIENZWIG_CHOICES)
self.fields["berufsgruppe"].choices = [("", "Bitte wählen...")] + list(Destinataer.BERUFSGRUPPE_CHOICES)
if "anrede" in self.fields:
self.fields["anrede"].choices = [("", "Bitte wählen...")] + list(Destinataer.ANREDE_CHOICES)
# Set choices for standard_konto to allow blank
self.fields["standard_konto"].empty_label = "---"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if field_name not in ["vorname", "nachname"]:
field.required = False
class DestinataerUnterstuetzungForm(forms.ModelForm):
"""Form für geplante/ausgeführte Destinatärunterstützungen"""

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2026-03-21 21:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0062_veranstaltungseinladung_vorlage'),
]
operations = [
migrations.AddField(
model_name='destinataer',
name='anrede',
field=models.CharField(blank=True, choices=[('Herr', 'Herr'), ('Frau', 'Frau'), ('Divers', 'Divers')], max_length=20, null=True, verbose_name='Anrede'),
),
]

View File

@@ -26,7 +26,20 @@ class Destinataer(models.Model):
("andere", "Andere"),
]
ANREDE_CHOICES = [
("Herr", "Herr"),
("Frau", "Frau"),
("Divers", "Divers"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
anrede = models.CharField(
max_length=20,
choices=ANREDE_CHOICES,
blank=True,
null=True,
verbose_name="Anrede",
)
familienzweig = models.CharField(
max_length=100, choices=FAMILIENZWIG_CHOICES, blank=True, null=True
)

View File

@@ -157,6 +157,18 @@
<div class="card-body py-2">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width:140px;">Anrede</td>
<td>
<span class="view-mode">{{ destinataer.anrede|default:"-" }}</span>
<select name="anrede" class="form-select form-select-sm edit-mode" style="display:none;">
<option value="">---</option>
<option value="Herr" {% if destinataer.anrede == 'Herr' %}selected{% endif %}>Herr</option>
<option value="Frau" {% if destinataer.anrede == 'Frau' %}selected{% endif %}>Frau</option>
<option value="Divers" {% if destinataer.anrede == 'Divers' %}selected{% endif %}>Divers</option>
</select>
</td>
</tr>
<tr>
<td class="text-muted" style="width:140px;">Vorname</td>
<td>

View File

@@ -48,6 +48,13 @@
<div class="card-body py-2">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width:140px;">Anrede</td>
<td>
{{ form.anrede }}
{% if form.anrede.errors %}<div class="invalid-feedback d-block">{{ form.anrede.errors.0 }}</div>{% endif %}
</td>
</tr>
<tr>
<td class="text-muted" style="width:140px;">Vorname *</td>
<td>