"""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)