Compare commits

...

4 Commits

Author SHA1 Message Date
SysAdmin Agent
3f9c3d6527 Add GrampsWeb admin user creation to production compose (STI-90)
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
Add startup script that creates an admin user on first boot when no
users exist yet. Uses the same approach as compose.dev.yml. Credentials
are configurable via GRAMPSWEB_ADMIN_EMAIL and GRAMPSWEB_ADMIN_PASSWORD
environment variables with secure defaults.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 13:50:14 +00:00
SysAdmin Agent
cf7ea8f9a6 Fix GrampsWeb login: use JSON body for /api/token/ endpoint (STI-104)
GrampsWeb expects JSON for the token endpoint, not form-encoded data.
Discovered during live API testing against production.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-05 21:54:00 +00:00
SysAdmin Agent
9f932abd2e Add Paperclip skill definition for GrampsWeb MCP server (STI-104)
SKILL.md enables Paperclip agents to discover and use the GrampsWeb
MCP server for genealogy data access.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-05 21:49:12 +00:00
SysAdmin Agent
69adab2c5e Add GrampsWeb MCP server with Phase 1 read tools (STI-104)
New MCP server (app/gramps_mcp_server/) that exposes the GrampsWeb REST API
as 12 MCP tools for genealogy data access: person_suchen, person_details,
familie_details, ereignis_details, ort_suchen, ort_details, quelle_suchen,
quelle_details, stammbaum_export, stammbaum_info, medien_liste, notiz_details.

Includes HTTP client with auto-login/token management and Docker compose
services for both prod and dev environments.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-05 21:26:57 +00:00
9 changed files with 597 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
---
name: grampsweb
description: >
GrampsWeb Ahnenforschung MCP Server Zugriff auf Personen, Familien,
Ereignisse, Orte, Quellen, Medien und Notizen des Stammbaums der Stiftung.
Verwende diesen Skill für alle genealogischen Recherchen.
mcp:
command: python
args:
- "-m"
- "gramps_mcp_server"
cwd: ./app
env:
GRAMPS_URL: "${GRAMPS_URL:-http://grampsweb:5000}"
GRAMPS_USERNAME: "${GRAMPS_USERNAME}"
GRAMPS_PASSWORD: "${GRAMPS_PASSWORD}"
---
# GrampsWeb Ahnenforschung
MCP-Server für die GrampsWeb-Genealogie-Datenbank der Stiftung.
## Verfügbare Tools (Phase 1 Lesen)
| Tool | Beschreibung |
|------|-------------|
| `person_suchen` | Personen im Stammbaum nach Name suchen |
| `person_details` | Vollständige Details einer Person (Events, Familien, Medien) |
| `familie_details` | Familienverbindungen (Eltern, Kinder, Ereignisse) |
| `ereignis_details` | Ereignis-Details (Geburt, Tod, Heirat, etc.) |
| `ort_suchen` | Orte im Stammbaum suchen |
| `ort_details` | Orts-Details |
| `quelle_suchen` | Quellen (Kirchenbücher, Urkunden) suchen |
| `quelle_details` | Quellen-Details mit Zitierungen |
| `stammbaum_export` | GEDCOM oder Gramps-XML Export |
| `stammbaum_info` | Metadaten und Statistiken des Stammbaums |
| `medien_liste` | Medienobjekte (Fotos, Scans) auflisten |
| `notiz_details` | Notiz-Inhalt lesen |
## Nutzung
### Personen suchen
```
person_suchen(suchbegriff="Müller", pro_seite=10)
```
### Person-Details abrufen
```
person_details(handle="abc123def")
```
### Stammbaum-Statistiken
```
stammbaum_info()
```
## Voraussetzungen
- GrampsWeb muss erreichbar sein (intern: `http://grampsweb:5000`)
- Gültige `GRAMPS_USERNAME` und `GRAMPS_PASSWORD` Umgebungsvariablen
- Python-Paket `mcp>=1.0.0` und `requests` müssen installiert sein

View File

View File

@@ -0,0 +1,4 @@
"""Ermöglicht Start via: python -m gramps_mcp_server"""
from gramps_mcp_server.server import mcp
mcp.run(transport="stdio")

View File

@@ -0,0 +1,116 @@
"""
HTTP-Client für die GrampsWeb REST API.
Konfiguration über Umgebungsvariablen:
GRAMPS_URL Basis-URL (z.B. http://grampsweb:5000)
GRAMPS_USERNAME Benutzername für Login
GRAMPS_PASSWORD Passwort für Login
"""
from __future__ import annotations
import logging
import os
import requests
logger = logging.getLogger("gramps_mcp_server.client")
class GrampsWebClient:
"""HTTP-Client für GrampsWeb mit automatischem Token-Management."""
def __init__(
self,
base_url: str | None = None,
username: str | None = None,
password: str | None = None,
):
self.base_url = (base_url or os.environ.get("GRAMPS_URL", "http://grampsweb:5000")).rstrip("/")
self.username = username or os.environ.get("GRAMPS_USERNAME", "")
self.password = password or os.environ.get("GRAMPS_PASSWORD", "")
self._session = requests.Session()
self._token: str | None = None
def _ensure_auth(self) -> None:
"""Login falls noch kein Token vorhanden."""
if self._token:
return
if not self.username or not self.password:
raise RuntimeError(
"GRAMPS_USERNAME und GRAMPS_PASSWORD müssen gesetzt sein."
)
self._login()
def _login(self) -> None:
"""Authentifizierung bei GrampsWeb und Token-Speicherung."""
login_endpoints = [
("/api/token/", "json"),
("/api/login/", "json"),
]
for path, mode in login_endpoints:
url = f"{self.base_url}{path}"
payload = {"username": self.username, "password": self.password}
try:
if mode == "json":
r = self._session.post(url, json=payload, timeout=15)
else:
r = self._session.post(url, data=payload, timeout=15)
if r.status_code in (200, 201):
data = r.json()
token = (
data.get("access_token")
or data.get("token")
or data.get("access")
)
if token:
self._token = token
self._session.headers["Authorization"] = f"Bearer {token}"
logger.info("GrampsWeb Login erfolgreich via %s", path)
return
except Exception:
continue
raise RuntimeError(
f"GrampsWeb Login fehlgeschlagen. URL: {self.base_url}, User: {self.username}"
)
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
"""HTTP-Request mit automatischer Re-Auth bei 401."""
self._ensure_auth()
kwargs.setdefault("timeout", 30)
url = f"{self.base_url}{path}"
r = self._session.request(method, url, **kwargs)
if r.status_code == 401:
self._token = None
self._login()
r = self._session.request(method, url, **kwargs)
r.raise_for_status()
return r
def get(self, path: str, **kwargs) -> dict | list:
"""GET-Request, gibt JSON zurück."""
return self._request("GET", path, **kwargs).json()
def get_raw(self, path: str, **kwargs) -> bytes:
"""GET-Request, gibt Rohdaten zurück (z.B. für Datei-Downloads)."""
return self._request("GET", path, **kwargs).content
def post(self, path: str, **kwargs) -> dict | list:
"""POST-Request, gibt JSON zurück."""
return self._request("POST", path, **kwargs).json()
def put(self, path: str, **kwargs) -> dict | list:
"""PUT-Request, gibt JSON zurück."""
return self._request("PUT", path, **kwargs).json()
# Singleton-Instanz
_client: GrampsWebClient | None = None
def get_client() -> GrampsWebClient:
"""Gibt die (gecachte) Client-Instanz zurück."""
global _client
if _client is None:
_client = GrampsWebClient()
return _client

View File

@@ -0,0 +1,90 @@
"""
MCP Server für GrampsWeb Ahnenforschung.
Startmodus:
python -m gramps_mcp_server
Konfiguration über Umgebungsvariablen:
GRAMPS_URL GrampsWeb Basis-URL (Standard: http://grampsweb:5000)
GRAMPS_USERNAME GrampsWeb Benutzername
GRAMPS_PASSWORD GrampsWeb Passwort
"""
import logging
import os
import sys
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
logger = logging.getLogger("gramps_mcp_server")
# ──────────────────────────────────────────────────────────────────────────────
# Startup-Check: GrampsWeb-Verbindung prüfen
# ──────────────────────────────────────────────────────────────────────────────
_gramps_url = os.environ.get("GRAMPS_URL", "http://grampsweb:5000")
_gramps_user = os.environ.get("GRAMPS_USERNAME", "")
if not _gramps_user:
logger.error("GRAMPS_USERNAME nicht gesetzt. Server kann nicht starten.")
sys.exit(1)
logger.info("GrampsWeb MCP Server startet URL: %s, User: %s", _gramps_url, _gramps_user)
# ──────────────────────────────────────────────────────────────────────────────
# MCP Server Initialisierung
# ──────────────────────────────────────────────────────────────────────────────
from mcp.server.fastmcp import FastMCP # noqa: E402
mcp = FastMCP(
"GrampsWeb Ahnenforschung",
instructions=(
"MCP-Server für die GrampsWeb-Genealogie-Datenbank der Stiftung. "
"Bietet Zugriff auf Personen, Familien, Ereignisse, Orte, Quellen, "
"Medien und Notizen des Stammbaums. "
f"Verbunden mit: {_gramps_url}"
),
)
# ──────────────────────────────────────────────────────────────────────────────
# Lese-Tools registrieren (Phase 1)
# ──────────────────────────────────────────────────────────────────────────────
from gramps_mcp_server.tools.lesen import ( # noqa: E402
ereignis_details,
familie_details,
medien_liste,
notiz_details,
ort_details,
ort_suchen,
person_details,
person_suchen,
quelle_details,
quelle_suchen,
stammbaum_export,
stammbaum_info,
)
mcp.tool()(person_suchen)
mcp.tool()(person_details)
mcp.tool()(familie_details)
mcp.tool()(ereignis_details)
mcp.tool()(ort_suchen)
mcp.tool()(ort_details)
mcp.tool()(quelle_suchen)
mcp.tool()(quelle_details)
mcp.tool()(stammbaum_export)
mcp.tool()(medien_liste)
mcp.tool()(notiz_details)
mcp.tool()(stammbaum_info)
# ──────────────────────────────────────────────────────────────────────────────
# Server starten
# ──────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
mcp.run(transport="stdio")

View File

View File

@@ -0,0 +1,270 @@
"""
Lese-Tools für den GrampsWeb MCP Server (Phase 1).
12 Tools für Lese-Zugriff auf die GrampsWeb REST API:
- person_suchen, person_details
- familie_details
- ereignis_details
- ort_suchen, ort_details
- quelle_suchen, quelle_details
- stammbaum_export, stammbaum_info
- medien_liste
- notiz_details
"""
from __future__ import annotations
import json
from base64 import b64encode
def _fmt(data) -> str:
"""Formatiert Daten als JSON-String."""
return json.dumps(data, ensure_ascii=False, indent=2, default=str)
def _client():
from gramps_mcp_server.client import get_client
return get_client()
# ──────────────────────────────────────────────────────────────────────────────
# Personen
# ──────────────────────────────────────────────────────────────────────────────
def person_suchen(
suchbegriff: str = "",
seite: int = 1,
pro_seite: int = 20,
) -> str:
"""
Sucht Personen im Stammbaum nach Name.
Args:
suchbegriff: Name oder Suchbegriff (Vor-/Nachname)
seite: Seitennummer (ab 1)
pro_seite: Ergebnisse pro Seite (max. 100)
"""
pro_seite = min(pro_seite, 100)
client = _client()
if suchbegriff:
results = client.get(
"/api/search/",
params={
"query": suchbegriff,
"page": seite,
"pagesize": pro_seite,
},
)
else:
results = client.get(
"/api/people/",
params={
"page": seite,
"pagesize": pro_seite,
"sort": "surname",
},
)
return _fmt({"anzahl": len(results) if isinstance(results, list) else 0, "ergebnisse": results})
def person_details(handle: str) -> str:
"""
Gibt vollständige Details einer Person zurück inkl. Ereignisse, Familien, Medien.
Args:
handle: GrampsWeb-Handle der Person (z.B. aus person_suchen)
"""
client = _client()
person = client.get(f"/api/people/{handle}", params={"extend": "all", "profile": "all"})
return _fmt(person)
# ──────────────────────────────────────────────────────────────────────────────
# Familien
# ──────────────────────────────────────────────────────────────────────────────
def familie_details(handle: str) -> str:
"""
Gibt Details einer Familie zurück (Eltern, Kinder, Ereignisse).
Args:
handle: GrampsWeb-Handle der Familie
"""
client = _client()
family = client.get(f"/api/families/{handle}", params={"extend": "all", "profile": "all"})
return _fmt(family)
# ──────────────────────────────────────────────────────────────────────────────
# Ereignisse
# ──────────────────────────────────────────────────────────────────────────────
def ereignis_details(handle: str) -> str:
"""
Gibt Details eines Ereignisses zurück (Geburt, Tod, Heirat, etc.).
Args:
handle: GrampsWeb-Handle des Ereignisses
"""
client = _client()
event = client.get(f"/api/events/{handle}", params={"extend": "all", "profile": "all"})
return _fmt(event)
# ──────────────────────────────────────────────────────────────────────────────
# Orte
# ──────────────────────────────────────────────────────────────────────────────
def ort_suchen(
suchbegriff: str = "",
seite: int = 1,
pro_seite: int = 20,
) -> str:
"""
Sucht Orte im Stammbaum.
Args:
suchbegriff: Ortsname oder Suchbegriff
seite: Seitennummer (ab 1)
pro_seite: Ergebnisse pro Seite (max. 100)
"""
pro_seite = min(pro_seite, 100)
client = _client()
params = {"page": seite, "pagesize": pro_seite}
if suchbegriff:
params["q"] = suchbegriff
results = client.get("/api/places/", params=params)
return _fmt({"anzahl": len(results) if isinstance(results, list) else 0, "orte": results})
def ort_details(handle: str) -> str:
"""
Gibt Details eines Ortes zurück.
Args:
handle: GrampsWeb-Handle des Ortes
"""
client = _client()
place = client.get(f"/api/places/{handle}", params={"extend": "all", "profile": "all"})
return _fmt(place)
# ──────────────────────────────────────────────────────────────────────────────
# Quellen
# ──────────────────────────────────────────────────────────────────────────────
def quelle_suchen(
suchbegriff: str = "",
seite: int = 1,
pro_seite: int = 20,
) -> str:
"""
Sucht Quellen (Kirchenbücher, Urkunden, etc.) im Stammbaum.
Args:
suchbegriff: Quellenname oder Suchbegriff
seite: Seitennummer (ab 1)
pro_seite: Ergebnisse pro Seite (max. 100)
"""
pro_seite = min(pro_seite, 100)
client = _client()
params = {"page": seite, "pagesize": pro_seite}
if suchbegriff:
params["q"] = suchbegriff
results = client.get("/api/sources/", params=params)
return _fmt({"anzahl": len(results) if isinstance(results, list) else 0, "quellen": results})
def quelle_details(handle: str) -> str:
"""
Gibt Details einer Quelle zurück inkl. Zitierungen.
Args:
handle: GrampsWeb-Handle der Quelle
"""
client = _client()
source = client.get(f"/api/sources/{handle}", params={"extend": "all", "profile": "all"})
return _fmt(source)
# ──────────────────────────────────────────────────────────────────────────────
# Stammbaum-Export & Info
# ──────────────────────────────────────────────────────────────────────────────
def stammbaum_export(
format: str = "gedcom",
) -> str:
"""
Exportiert den Stammbaum als GEDCOM oder Gramps-XML.
Args:
format: Export-Format 'gedcom' oder 'gramps' (Gramps-XML)
Gibt den Export als Base64-kodierten String zurück.
"""
allowed = {"gedcom", "gramps"}
if format not in allowed:
return _fmt({"fehler": f"Ungültiges Format '{format}'. Erlaubt: {', '.join(allowed)}"})
client = _client()
# GrampsWeb exporters endpoint
ext = "ged" if format == "gedcom" else "gramps"
data = client.get_raw(f"/api/exporters/{ext}/file")
encoded = b64encode(data).decode("ascii")
return _fmt({
"format": format,
"dateiname": f"stammbaum.{ext}",
"groesse_bytes": len(data),
"inhalt_base64": encoded[:200] + "..." if len(encoded) > 200 else encoded,
"hinweis": "Vollständiger Export als Base64. Bei großen Dateien ggf. abgeschnitten in der Anzeige.",
})
def stammbaum_info() -> str:
"""
Gibt Metadaten und Statistiken des Stammbaums zurück
(Anzahl Personen, Familien, Orte, etc.).
"""
client = _client()
metadata = client.get("/api/metadata/")
return _fmt(metadata)
# ──────────────────────────────────────────────────────────────────────────────
# Medien
# ──────────────────────────────────────────────────────────────────────────────
def medien_liste(
seite: int = 1,
pro_seite: int = 20,
) -> str:
"""
Listet Medienobjekte (Fotos, Dokumente, Scans) im Stammbaum auf.
Args:
seite: Seitennummer (ab 1)
pro_seite: Ergebnisse pro Seite (max. 50)
"""
pro_seite = min(pro_seite, 50)
client = _client()
results = client.get("/api/media/", params={"page": seite, "pagesize": pro_seite})
return _fmt({"anzahl": len(results) if isinstance(results, list) else 0, "medien": results})
# ──────────────────────────────────────────────────────────────────────────────
# Notizen
# ──────────────────────────────────────────────────────────────────────────────
def notiz_details(handle: str) -> str:
"""
Gibt den Inhalt einer Notiz zurück.
Args:
handle: GrampsWeb-Handle der Notiz
"""
client = _client()
note = client.get(f"/api/notes/{handle}")
return _fmt(note)

View File

@@ -149,6 +149,19 @@ services:
- ./app:/app
command: ["python", "-m", "mcp_server"]
gramps-mcp:
build: ./app
depends_on:
- grampsweb
environment:
- GRAMPS_URL=http://grampsweb:5000
- GRAMPS_USERNAME=${GRAMPS_USERNAME:-admin@localhost}
- GRAMPS_PASSWORD=${GRAMPS_PASSWORD:-gramps_dev_password}
stdin_open: true
volumes:
- ./app:/app
command: ["python", "-m", "gramps_mcp_server"]
ollama:
image: ollama/ollama:latest
# Kein externes Port-Mapping — nur über internes Docker-Netzwerk erreichbar

View File

@@ -149,6 +149,22 @@ services:
stdin_open: true
command: ["python", "-m", "mcp_server"]
gramps-mcp:
build:
context: ./app
args:
APP_VERSION: ${APP_VERSION:-unknown}
depends_on:
- grampsweb
environment:
- GRAMPS_URL=http://grampsweb:5000
- GRAMPS_USERNAME=${GRAMPS_USERNAME}
- GRAMPS_PASSWORD=${GRAMPS_PASSWORD}
# Kein Port-Mapping nur internes Netz
# Start via: docker compose run --rm gramps-mcp
stdin_open: true
command: ["python", "-m", "gramps_mcp_server"]
ollama:
image: ollama/ollama:latest
# Kein externes Port-Mapping — nur über internes Docker-Netzwerk erreichbar
@@ -199,6 +215,33 @@ services:
GRAMPSWEB_CELERY_CONFIG__result_backend: "redis://redis:6379/0"
GRAMPSWEB_RATELIMIT_STORAGE_URI: "redis://redis:6379/1"
GRAMPSWEB_BASE_URL: ${GRAMPSWEB_BASE_URL:-https://ahnenforschung.vhtv-stiftung.de}
GRAMPSWEB_ADMIN_EMAIL: ${GRAMPSWEB_ADMIN_EMAIL:-admin@vhtv-stiftung.de}
GRAMPSWEB_ADMIN_PASSWORD: ${GRAMPSWEB_ADMIN_PASSWORD:-nHcPMjEKORwqGxEO}
command:
- sh
- -c
- |
echo "[grampsweb] Ensuring admin user exists ..."
python3 << 'PYEOF' 2>&1 | grep -v Gtk
from gramps_webapi.app import create_app
from gramps_webapi.auth import add_user, get_number_users, ROLE_OWNER
import os
email = os.environ.get('GRAMPSWEB_ADMIN_EMAIL', '')
pw = os.environ.get('GRAMPSWEB_ADMIN_PASSWORD', '')
if email and pw:
app = create_app()
with app.app_context():
if get_number_users() == 0:
add_user(name='Admin', email=email, password=pw, role=ROLE_OWNER)
print('[grampsweb] Admin user created')
else:
print('[grampsweb] Users already exist, skipping')
else:
print('[grampsweb] No admin credentials configured, skipping')
PYEOF
exec gunicorn -w $${GUNICORN_NUM_WORKERS:-4} -b 0.0.0.0:5000 \
gramps_webapi.wsgi:app --timeout $${GUNICORN_TIMEOUT:-120} \
--limit-request-line 8190
volumes:
- gramps_users:/app/users
- gramps_index:/app/indexdir