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

176 lines
9.1 KiB
Python

"""Datenbanken eines Workspace als MCP-Verbindungen über einen Datenbank-Treiber.
Ein Treiber ist ein DB-Dienst aus services/db-mcp (z. B. db-mcp-oracle), den der Plattform-Admin einmal
registriert (Seite „Datenbank-Treiber“, Rolle database) und dessen Tools er einmal einstuft. Er hat keine
eigenen Daten; sein Schlüssel öffnet nur den Dienst. Workspace-Admins legen Datenbanken mit Host,
Datenbank, Nutzer und Passwort an; Bifröst schickt diese je Aufruf als Header X-DB-*, das Modell sieht sie
nie. Die Tools einer Datenbank sind die des Treibers (übernommen, gleicher Fingerabdruck) und haben dessen
Einstufung. Den SQL-Dialekt beschreibt der Treiber in seiner Anleitung.
"""
import re
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import audit
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.database import now
from app.core.errors import Invalid
from app.identity import Principal
from app.mcp import client
from app.mcp import service as mcp
from app.mcp.models import APPROVED, McpServer, McpTool
from app.tools.registry import ToolError
from app.workspaces import service as workspaces
HOST = re.compile(r"^[A-Za-z0-9.-]{1,253}$")
IDENTIFIER = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.$#-]{0,127}$")
LOCAL = {"localhost", "127.0.0.1", "::1"}
SSL_MODES = {"require": "erzwingen", "verify-full": "erzwingen und Zertifikat prüfen",
"prefer": "wenn möglich (nur lokal, nur PostgreSQL)", "disable": "aus (nur lokal)"}
PASSWORD = "X-DB-Password"
def drivers(session: Session) -> list[McpServer]:
"""Nutzbare Treiber: aktiv und Tools abgerufen."""
return list(session.scalars(select(McpServer).where(
McpServer.workspace_id.is_(None), McpServer.role == mcp.DATABASE, McpServer.active.is_(True),
McpServer.status == "ok").order_by(McpServer.label)))
def check_config(host: str, port: str | int | None, database: str, user: str, sslmode: str,
settings: Settings) -> dict[str, Any]:
"""Zusätzlich zur Prüfung im Treiber: nur freigegebene Hosts, TLS außer lokal."""
host, database, user = host.strip().lower(), database.strip(), user.strip()
if not HOST.match(host):
raise Invalid("Ungültiger Host.")
if host in LOCAL:
if not settings.db_allow_local:
raise Invalid("Lokale Datenbanken sind nicht freigegeben (AIW_DB_ALLOW_LOCAL).")
elif host not in settings.allowed_db_hosts:
raise Invalid(f"Host {host} ist nicht freigegeben (AIW_DB_ALLOWED_HOSTS).")
if port in (None, ""):
port = None
else:
try:
port = int(port)
except ValueError:
raise Invalid("Der Port muss eine Zahl sein.") from None
if not 1 <= port <= 65535:
raise Invalid("Ungültiger Port.")
if not IDENTIFIER.match(database):
raise Invalid("Ungültiger Wert für Datenbank bzw. Service-Name.")
if not IDENTIFIER.match(user):
raise Invalid("Ungültiger Benutzername.")
if sslmode not in SSL_MODES:
raise Invalid("Unbekannter TLS-Modus.")
if sslmode in ("prefer", "disable") and host not in LOCAL:
raise Invalid("Ohne erzwungenes TLS nur für lokale Datenbanken.")
return {"host": host, "port": port, "database": database, "user": user, "sslmode": sslmode}
def headers(server: McpServer) -> dict[str, str]:
"""Verbindungsdaten als Header; das Passwort liegt verschlüsselt bei den übrigen Headern."""
c = server.config or {}
result = {"X-DB-Host": c.get("host", ""), "X-DB-Database": c.get("database", ""), "X-DB-User": c.get("user", ""),
"X-DB-SSLMode": c.get("sslmode", "verify-full")}
if c.get("port"):
result["X-DB-Port"] = str(c["port"])
return result
def copy_tools(session: Session, driver: McpServer, server: McpServer) -> None:
"""Die Tools des Treibers für die Datenbank übernehmen (gleicher Fingerabdruck → gleiche Einstufung)."""
existing = {t.remote_name: t for t in mcp.tools_of(session, server.id)}
for tool in mcp.tools_of(session, driver.id):
row = existing.pop(tool.remote_name, None) or McpTool(server_id=server.id, remote_name=tool.remote_name)
row.name, row.description, row.input_schema = mcp.model_name(server, tool.remote_name), tool.description, \
tool.input_schema
row.annotations, row.fingerprint = tool.annotations, tool.fingerprint
session.add(row)
for gone in existing.values():
session.delete(gone)
server.server_info, server.discovered_at = driver.server_info, now()
def driver_of(session: Session, driver_id: int | str) -> McpServer:
try:
driver = session.get(McpServer, int(driver_id))
except ValueError:
driver = None
if driver is None or driver not in drivers(session):
raise Invalid("Bitte einen Datenbanktyp wählen.")
return driver
def create(session: Session, principal: Principal, settings: Settings, vault: Vault, workspace_id: int, *,
driver_id: int | str, name: str, label: str, host: str, port: str, database: str, user: str,
password: str, sslmode: str, exposure_confirmed: bool) -> McpServer:
workspaces.require_admin(session, principal, workspace_id)
if not exposure_confirmed:
raise Invalid("Bitte bestätigen, dass alle Mitglieder des Workspace diese Daten sehen dürfen.")
driver = driver_of(session, driver_id)
name, config = mcp.check_name(session, name), check_config(host, port, database, user, sslmode, settings)
server = McpServer(workspace_id=workspace_id, template_id=driver.id, name=name,
label=label.strip()[:200] or name, url=driver.url, config=config, approval=APPROVED,
exposure_confirmed_by=principal.user_id, exposure_confirmed_at=now(),
created_by=principal.user_id)
mcp.set_headers(server, vault, {PASSWORD: password} if password else {})
session.add(server)
session.flush()
copy_tools(session, driver, server)
audit.record(session, "mcp.database_created", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="mcp_server", target_id=server.id, name=name, driver=driver.name,
host=config["host"], database=config["database"], user=config["user"], password=bool(password))
audit.record(session, "source.exposure_confirmed", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="mcp_server", target_id=server.id)
return server
def update(session: Session, principal: Principal, settings: Settings, vault: Vault, server_id: int, *,
label: str, user: str, password: str = "", remove_password: bool = False, sslmode: str,
active: bool = True) -> McpServer:
"""Bezeichnung, Nutzer, Passwort und TLS. Treiber, Host und Datenbank bleiben: ein anderes Ziel ist eine
neue Datenbank mit neuer Bestätigung."""
server = mcp.connection(session, principal, server_id)
if not server.template_id:
raise Invalid("Das ist keine Datenbank-Verbindung.")
c = server.config
config = check_config(c["host"], c.get("port"), c["database"], user, sslmode, settings)
changed = config != c or bool(password) or (remove_password and bool(server.headers))
server.label, server.config, server.active = label.strip()[:200] or server.name, config, active
if password:
mcp.set_headers(server, vault, {PASSWORD: password})
elif remove_password:
mcp.set_headers(server, vault, {})
if changed:
server.status, server.status_message = "new", "Geändert, bitte neu testen."
audit.record(session, "mcp.database_updated", user_id=principal.user_id, workspace_id=server.workspace_id,
target_kind="mcp_server", target_id=server.id, user=config["user"], sslmode=sslmode, active=active,
password="ersetzt" if password else "entfernt" if remove_password else "unverändert")
return server
def test(session: Session, settings: Settings, vault: Vault, server: McpServer, user_id: int | None) -> None:
"""Tools vom Treiber übernehmen und die Verbindung zur Datenbank prüfen (check_connection)."""
driver = session.get(McpServer, server.template_id)
copy_tools(session, driver, server)
try:
failed, text, _ = client.call_result(mcp.target(server, vault, settings, template=driver),
"check_connection", {})
except ToolError as e:
failed, text = True, str(e).replace("Der MCP-Server", "Der Datenbank-Treiber")
text = re.sub(r"^Error executing tool [^:]+: ", "", text)[:1000]
server.status = "error" if failed else "ok"
server.status_message, server.last_error = text, text if failed else ""
audit.record(session, "mcp.database_tested", user_id=user_id, workspace_id=server.workspace_id,
target_kind="mcp_server", target_id=server.id, ok=not failed, message=text[:300])
def driver_refreshed(session: Session, driver: McpServer) -> None:
"""Nach dem Abruf der Tools eines Treibers: seine Datenbanken angleichen."""
for server in session.scalars(select(McpServer).where(McpServer.template_id == driver.id)):
copy_tools(session, driver, server)