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

577 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""MCP Client Manager (→ Konzept 7.2): Registrierung, Discovery, Freischaltung je Workspace,
Verbindungen der Workspaces und die entdeckten Tools als Teil des Tool-Katalogs.
Verbindungen eines Workspace: angelegt vom Workspace-Admin, nur dort nutzbar. Ist die URL schon
freigegeben (zentraler Server oder freigegebene Verbindung), ist die Verbindung sofort aktiv, sonst
wartet sie auf die Freigabe eines Plattform-Admins. Gleiche Tools desselben Dienstes (Fingerabdruck)
erben die Einstufung (app.tools.service.sync).
Die Tools werden aus dem Stand der letzten Discovery gebaut; ein Run ruft den
Server erst beim Tool-Aufruf an. Ein nicht erreichbarer Server führt so zu einem
Tool-Fehler, nicht zu einem abgebrochenen Run.
"""
import hashlib
import json
import re
from functools import partial
from typing import Any
from urllib.parse import urlsplit
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import audit
from app.ai.service import LOCAL_HOSTS
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.database import now
from app.core.errors import Forbidden, Invalid, NotFound
from app.data_sources.service import check_token
from app.identity import Principal
from app.identity.admin import require as require_platform_admin
from app.identity.models import User
from app.mcp import client
from app.mcp.models import APPROVED, PENDING, REJECTED, McpServer, McpTool, WorkspaceMcpServer
from app.tools.registry import Proposal, TableResult, Tool, ToolError
from app.workspaces import service as workspaces
NAME = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
TOOL_NAME_LIMIT = 64 # Vorgabe der Responses API für Funktionsnamen
COMBINATORS = {"anyOf", "oneOf", "allOf", "enum", "not", "const"}
# Art des Servers: allgemein, Gedächtnis (Munin, → Konzept 7.6) oder Datenbank-Treiber. Ein Treiber
# (DB-Dienst aus services/db-mcp) hat keine eigenen Daten; Workspaces legen damit Datenbanken an
# (app.mcp.databases). Treiber verwaltet die eigene Seite /admin/databases.
# Dokumenten-Auslese: liest Office, PDF und Bilder für abgeglichene Ordner aus (app.documents); welches Tool
# und welche Parameter, legt der Plattform-Admin in config fest
ROLES = {"": "Allgemein", "memory": "Gedächtnis (z. B. Munin)", "database": "Datenbank-Treiber",
"documents": "Dokumenten-Auslese"}
DATABASE, DOCUMENTS = "database", "documents"
SERVER_ROLES = {k: v for k, v in ROLES.items() if k != DATABASE}
HEADER_NAME = re.compile(r"^[A-Za-z0-9-]{1,64}$")
# Diese Header setzt Bifröst bzw. der Transport selbst
RESERVED_HEADERS = {"authorization", "host", "content-type", "content-length", "accept", "cookie", "connection",
"transfer-encoding", "mcp-protocol-version", "mcp-session-id"}
MAX_HEADERS = 20
def kind(server_name: str) -> str:
return f"mcp:{server_name}"
# --- Registrierung (Plattform-Admin) ---
def check_url(url: str, settings: Settings) -> str:
url = url.strip()
parts = urlsplit(url)
if parts.username or parts.password:
raise Invalid("Keine Zugangsdaten in der URL. Das Token wird separat hinterlegt.")
host = (parts.hostname or "").lower()
if parts.scheme == "http" and host in LOCAL_HOSTS and settings.mcp_allow_local:
return url
if parts.scheme != "https" or not host:
raise Invalid("Nur https:// (http:// nur für localhost mit AIW_MCP_ALLOW_LOCAL).")
if host not in settings.allowed_mcp_hosts:
raise Invalid(f"Host {host} ist nicht freigegeben (AIW_MCP_ALLOWED_HOSTS).")
return url
def servers(session: Session) -> list[McpServer]:
"""Zentrale Server ohne Datenbank-Treiber."""
return list(session.scalars(select(McpServer).where(McpServer.workspace_id.is_(None), McpServer.role != DATABASE)
.order_by(McpServer.name)))
def drivers(session: Session) -> list[McpServer]:
"""Datenbank-Treiber, auch inaktive."""
return list(session.scalars(select(McpServer).where(McpServer.workspace_id.is_(None), McpServer.role == DATABASE)
.order_by(McpServer.label)))
def origins(session: Session) -> dict[str, McpServer]:
"""Tool-Art → Server, für die Tool-Übersicht."""
return {kind(s.name): s for s in session.scalars(select(McpServer))}
def connections(session: Session, workspace_id: int | None = None) -> list[McpServer]:
"""Verbindungen der Workspaces (eines Workspace oder aller)."""
query = select(McpServer).where(McpServer.workspace_id.is_not(None))
if workspace_id is not None:
query = query.where(McpServer.workspace_id == workspace_id)
return list(session.scalars(query.order_by(McpServer.name)))
def check_name(session: Session, name: str) -> str:
"""Kurzname, Präfix der Tool-Namen; über alle Workspaces eindeutig (Einstufung und Regeln hängen daran)."""
name = name.strip().lower()
if not NAME.match(name) or not 2 <= len(name) <= 16:
raise Invalid("Kurzname: 2–16 Zeichen, Kleinbuchstaben, Ziffern und einzelne Unterstriche.")
if session.scalar(select(McpServer.id).where(McpServer.name == name)):
raise Invalid(f"Den Kurznamen {name} gibt es schon (auch andere Workspaces zählen); bitte einen anderen "
"wählen.")
return name
def check_headers(text: str) -> dict[str, str]:
"""Zeilen „Name: Wert“; leere Zeilen werden ignoriert."""
headers: dict[str, str] = {}
for line in text.splitlines():
if not line.strip():
continue
name, sep, value = line.partition(":")
name, value = name.strip(), value.strip()
if not sep or not HEADER_NAME.match(name) or not value:
raise Invalid(f"Header bitte als „Name: Wert“, je Zeile einer: {name[:40]!r}")
if name.lower() in RESERVED_HEADERS:
raise Invalid(f"Den Header {name} setzt Bifröst selbst; das Token gehört ins Feld Token.")
if any(ord(c) < 32 for c in value) or len(value) > 4000:
raise Invalid(f"Ungültiger Wert für {name}.")
headers[name] = value
if len(headers) > MAX_HEADERS:
raise Invalid(f"Höchstens {MAX_HEADERS} Header.")
return headers
def get(session: Session, server_id: int) -> McpServer:
server = session.get(McpServer, server_id)
if server is None:
raise NotFound
return server
def tools_of(session: Session, server_id: int) -> list[McpTool]:
return list(session.scalars(select(McpTool).where(McpTool.server_id == server_id).order_by(McpTool.name)))
def save(session: Session, principal: Principal, settings: Settings, vault: Vault, server_id: int | None, *,
name: str, label: str, url: str, token: str = "", remove_token: bool = False,
active: bool = True, role: str = "") -> McpServer:
require_platform_admin(principal)
url, token = check_url(url, settings), check_token(token)
if role not in ROLES:
raise Invalid("Unbekannte Art des Servers.")
if server_id is None:
server = McpServer(name=check_name(session, name), created_by=principal.user_id, label="", url="")
session.add(server)
else:
server = get(session, server_id)
if server.workspace_id is not None:
raise Invalid("Das ist eine Verbindung eines Workspace; sie wird dort bearbeitet.")
if (server.role == DATABASE) != (role == DATABASE):
raise Invalid("Ein Datenbank-Treiber bleibt ein Treiber und ein Server ein Server.")
if server.url and server.url != url:
server.status = "new" # neuer Endpunkt: Tools neu abrufen
for connection_ in session.scalars(select(McpServer).where(McpServer.template_id == server.id)):
connection_.url = url
server.label, server.url, server.active = label.strip()[:200] or server.name, url, active
server.role = role
if token:
server.secret, server.secret_hint = vault.encrypt(token), token[-4:]
elif remove_token:
server.secret, server.secret_hint = None, ""
session.flush()
audit.record(session, "mcp.server_saved", user_id=principal.user_id, target_kind="mcp_server",
target_id=server.id, name=server.name, url=url, active=active, role=role)
return server
def set_documents(session: Session, principal: Principal, server_id: int, tool: str, content: str,
filename: str = "") -> McpServer:
"""Dokumenten-Auslese festlegen: welches Tool, welcher Parameter bekommt den Inhalt (Base64), welcher den
Dateinamen. Geprüft gegen das Schema aus der letzten Discovery."""
require_platform_admin(principal)
server = get(session, server_id)
if server.role != DOCUMENTS or server.workspace_id is not None:
raise Invalid("Nur für zentrale Server mit der Art „Dokumenten-Auslese“.")
found = session.scalar(select(McpTool).where(McpTool.server_id == server.id, McpTool.remote_name == tool))
if found is None:
raise Invalid("Dieses Tool hat der Server bei der letzten Discovery nicht gemeldet.")
properties = (found.input_schema or {}).get("properties", {})
if content not in properties:
raise Invalid(f"Das Tool hat keinen Parameter {content!r}.")
if filename and (filename not in properties or filename == content):
raise Invalid(f"Ungültiger Parameter für den Dateinamen: {filename!r}.")
server.config = {**(server.config or {}), "tool": tool, "content": content, "filename": filename}
audit.record(session, "mcp.documents_configured", user_id=principal.user_id, target_kind="mcp_server",
target_id=server.id, tool=tool, content=content, filename=filename)
return server
def uses(session: Session, template_id: int) -> int:
"""Wie viele Datenbanken diesen Treiber nutzen."""
return len(list(session.scalars(select(McpServer.id).where(McpServer.template_id == template_id))))
def delete(session: Session, principal: Principal, server_id: int) -> None:
require_platform_admin(principal)
server = get(session, server_id)
if uses(session, server.id):
raise Invalid("Datenbanken von Workspaces nutzen diesen Treiber; erst diese entfernen.")
audit.record(session, "mcp.server_deleted", user_id=principal.user_id, target_kind="mcp_server",
target_id=server.id, name=server.name)
session.delete(server)
def target(server: McpServer, vault: Vault, settings: Settings, secret: str | None = None,
template: McpServer | None = None) -> client.Target:
"""Ziel eines Aufrufs. Token: eigenes des Workspace, sonst das des Servers bzw. bei Datenbanken der Schlüssel
des Treibers. Header: die eigenen und bei Datenbanken die Verbindungsdaten (X-DB-*)."""
encrypted = secret or server.secret or (template.secret if template else None)
headers = json.loads(vault.decrypt(server.headers)) if server.headers else {}
if template is not None:
from app.mcp import databases
headers = {**databases.headers(server), **headers}
url = template.url if template is not None else server.url
return client.Target(url, vault.decrypt(encrypted) if encrypted else None, settings.mcp_timeout_seconds, headers)
# --- Discovery ---
def model_name(server: McpServer, remote: str) -> str:
return f"{server.name}__{re.sub(r'[^A-Za-z0-9_-]', '_', remote)}"
def fingerprint(url: str, remote: str, description: str, schema: dict[str, Any]) -> str:
"""Gleiches Tool desselben Dienstes, unabhängig von Kurzname und Bezeichnung der Verbindung."""
data = json.dumps({"url": url.rstrip("/"), "tool": remote, "description": description, "schema": schema},
sort_keys=True, ensure_ascii=False)
return hashlib.sha256(data.encode()).hexdigest()
def normalize(schema: Any) -> dict[str, Any]:
"""Schema so, wie es die Responses API für Funktionen verlangt: oben ein Objekt."""
if not isinstance(schema, dict):
raise ValueError("kein Objekt")
if COMBINATORS & set(schema) or schema.get("type", "object") != "object":
raise ValueError("oben kein einfaches Objekt (anyOf/oneOf/…)")
schema = {k: v for k, v in schema.items() if k not in ("$schema", "title")}
return {"type": "object", "properties": {}, **schema}
def discover(session: Session, principal: Principal, settings: Settings, vault: Vault,
server_id: int) -> tuple[str, list[str]]:
"""Tools eines zentralen Servers abrufen (Plattform-Admin)."""
require_platform_admin(principal)
server = get(session, server_id)
error, notes = refresh(session, settings, vault, server, principal.user_id)
if not error and server.role == DATABASE:
from app.mcp import databases
databases.driver_refreshed(session, server)
return error, notes
def refresh(session: Session, settings: Settings, vault: Vault, server: McpServer,
user_id: int | None) -> tuple[str, list[str]]:
"""Tools abrufen und speichern. Liefert (Fehler, Hinweise zu übersprungenen Tools); ein Fehler
wird am Server vermerkt, nicht geworfen. Geänderte Tools verlieren über den Hash ihre Einstufung."""
template = session.get(McpServer, server.template_id) if server.template_id else None
try:
found = client.discover(target(server, vault, settings, template=template))
except ToolError as e:
server.status, server.last_error = "error", str(e)
audit.record(session, "mcp.discovery_failed", user_id=user_id, target_kind="mcp_server",
target_id=server.id, error=str(e)[:300])
return str(e), []
existing = {t.remote_name: t for t in tools_of(session, server.id)}
taken = set(session.scalars(select(McpTool.name).where(McpTool.server_id != server.id)))
notes, seen = [], set()
for remote in found.tools:
name = model_name(server, remote.name)
try:
schema = normalize(remote.input_schema)
except ValueError as e:
notes.append(f"{remote.name}: Schema nicht verwendbar ({e}).")
continue
if len(name) > TOOL_NAME_LIMIT or name in taken or name in seen:
notes.append(f"{remote.name}: Name zu lang oder nicht eindeutig.")
continue
seen.add(name)
row = existing.pop(remote.name, None) or McpTool(server_id=server.id, remote_name=remote.name)
row.name, row.description, row.input_schema = name, remote.description[:4000], schema
row.annotations = remote.annotations
row.fingerprint = fingerprint(server.url, remote.name, row.description, schema)
session.add(row)
for gone in existing.values():
session.delete(gone)
server.status, server.last_error, server.discovered_at = "ok", "", now()
server.server_info = found.server_info
session.flush()
audit.record(session, "mcp.discovered", user_id=user_id, target_kind="mcp_server",
target_id=server.id, tools=len(seen), skipped=len(notes))
return "", notes
# --- Tools für den Katalog ---
def suggest(annotations: dict[str, Any]) -> str:
"""Vorschlag aus den Annotations; der Server ist nicht vertrauenswürdig, entschieden wird von Hand."""
if annotations.get("readOnlyHint") is True:
return "read"
if annotations.get("destructiveHint") is True:
return "destructive"
return "write_external"
def run_tool(server_name: str, remote: str, context: Any, arguments: dict[str, Any]) -> str:
"""Ergebnis als Text; liefert der Server Spalten und Zeilen (structuredContent), zusätzlich als Tabelle
für Ansicht, CSV und Diagramm."""
target_ = context.mcp.get(server_name)
if target_ is None:
raise ToolError(f"Der MCP-Server {server_name} ist für diesen Workspace nicht freigeschaltet.")
failed, text, data = client.call_result(target_, remote, arguments)
if failed:
raise ToolError(f"Das Tool meldet einen Fehler: {re.sub(r'^Error executing tool [^:]+: ', '', text)[:1000]}")
return as_table(text, data, context.settings.query_max_rows)
def as_table(text: str, data: Any, max_rows: int) -> str:
if not isinstance(data, dict):
return text
columns, rows = data.get("columns"), data.get("rows")
if not (isinstance(columns, list) and isinstance(rows, list) and all(isinstance(c, str) for c in columns)
and all(isinstance(r, list) and len(r) == len(columns) for r in rows)):
return text
return TableResult(text, columns, rows[:max_rows], bool(data.get("truncated")) or len(rows) > max_rows)
def prepare_tool(server_name: str, remote: str, context: Any, arguments: dict[str, Any]) -> Proposal:
"""Freigabe für MCP-Tools: der Antrag zeigt die geprüften Argumente, die genau so gesendet werden."""
if server_name not in context.mcp:
raise ToolError(f"Der MCP-Server {server_name} ist für diesen Workspace nicht freigeschaltet.")
return Proposal(source_id=None, arguments=arguments,
preview=json.dumps(arguments, indent=2, ensure_ascii=False, sort_keys=True),
preconditions={"server": server_name, "tool": remote})
def execute_tool(server_name: str, remote: str, context: Any, snapshot: dict[str, Any]) -> tuple[str, dict]:
try:
return run_tool(server_name, remote, context, snapshot["arguments"]), {}
except ToolError as e:
# MCP-Aufrufe sind nicht idempotent: nie automatisch wiederholen
raise ToolError(f"{e} Ob die Aktion im Zielsystem ausgeführt wurde, ist unklar; bitte dort prüfen. "
"Sie wird nicht automatisch wiederholt.") from e
def catalog_tools(session: Session) -> dict[str, Tool]:
rows = session.execute(select(McpTool, McpServer).join(McpServer, McpServer.id == McpTool.server_id)
.where(McpServer.active.is_(True), McpServer.approval == APPROVED)).all()
result = {}
for row, server in rows:
bound = (server.name, row.remote_name)
result[row.name] = Tool(
row.name, kind(server.name), f"[{server.label}] {row.description}".strip(), None,
suggest(row.annotations), run=partial(run_tool, *bound), prepare=partial(prepare_tool, *bound),
execute=partial(execute_tool, *bound), input_schema=row.input_schema,
fingerprint=row.fingerprint or None)
return result
# --- Freischaltung je Workspace ---
def workspace_servers(session: Session, workspace_id: int) -> list[tuple[McpServer, WorkspaceMcpServer | None]]:
"""Zentrale Server zum Freischalten; Treiber nicht, die nutzt man über Datenbanken."""
links = {link.server_id: link for link in session.scalars(
select(WorkspaceMcpServer).where(WorkspaceMcpServer.workspace_id == workspace_id))}
return [(s, links.get(s.id)) for s in servers(session) if s.active or s.id in links]
def enable(session: Session, principal: Principal, vault: Vault, workspace_id: int, server_id: int,
token: str = "") -> None:
workspaces.require_admin(session, principal, workspace_id)
server = get(session, server_id)
if not server.active:
raise Invalid("Dieser Server ist deaktiviert.")
if server.workspace_id is not None or server.role == DATABASE:
raise Invalid("Dieser Server lässt sich nicht freischalten.")
token = check_token(token)
link = session.get(WorkspaceMcpServer, (workspace_id, server_id)) or WorkspaceMcpServer(
workspace_id=workspace_id, server_id=server_id, enabled_by=principal.user_id)
if token:
link.secret, link.secret_hint = vault.encrypt(token), token[-4:]
session.add(link)
audit.record(session, "mcp.workspace_enabled", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="mcp_server", target_id=server_id, own_token=bool(token))
def disable(session: Session, principal: Principal, workspace_id: int, server_id: int) -> None:
workspaces.require_admin(session, principal, workspace_id)
link = session.get(WorkspaceMcpServer, (workspace_id, server_id))
if link is not None:
session.delete(link)
audit.record(session, "mcp.workspace_disabled", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="mcp_server", target_id=server_id)
def enabled(session: Session, workspace_id: int) -> list[tuple[McpServer, WorkspaceMcpServer | None]]:
"""Was ein Run dieses Workspace nutzen darf: freigeschaltete zentrale Server und die eigenen,
freigegebenen Verbindungen (ohne Freischaltung, daher None)."""
central = list(session.execute(
select(McpServer, WorkspaceMcpServer).join(WorkspaceMcpServer, WorkspaceMcpServer.server_id == McpServer.id)
.where(WorkspaceMcpServer.workspace_id == workspace_id, McpServer.active.is_(True),
McpServer.workspace_id.is_(None), McpServer.role != DATABASE)).all())
own = session.scalars(select(McpServer).where(McpServer.workspace_id == workspace_id, McpServer.active.is_(True),
McpServer.approval == APPROVED).order_by(McpServer.name))
return [(s, link) for s, link in central] + [(s, None) for s in own]
def kinds(session: Session, workspace_id: int) -> set[str]:
return {kind(server.name) for server, _ in enabled(session, workspace_id)}
def targets(session: Session, vault: Vault, settings: Settings, workspace_id: int) -> dict[str, client.Target]:
"""Ziele für einen Run; ein eigenes Token des Workspace hat Vorrang vor dem des Servers."""
result = {}
for server, link in enabled(session, workspace_id):
template = session.get(McpServer, server.template_id) if server.template_id else None
result[server.name] = target(server, vault, settings, link.secret if link else None, template)
return result
def foreign_kinds(session: Session, workspace_id: int) -> set[str]:
"""Tool-Arten, die dieser Workspace nie sieht: Verbindungen anderer Workspaces und Treiber."""
rows = session.scalars(select(McpServer.name).where(
((McpServer.workspace_id.is_not(None)) & (McpServer.workspace_id != workspace_id))
| (McpServer.role == DATABASE)))
return {kind(name) for name in rows}
# --- Verbindungen eines Workspace ---
def approved_url(session: Session, url: str, exclude: int | None = None) -> bool:
"""Die Plattform nutzt diese URL schon: zentraler Server oder freigegebene Verbindung."""
query = select(McpServer.id).where(McpServer.url == url, McpServer.approval == APPROVED)
if exclude is not None:
query = query.where(McpServer.id != exclude)
return session.scalar(query.limit(1)) is not None
def connection(session: Session, principal: Principal, server_id: int) -> McpServer:
"""Verbindung eines Workspace, nur für dessen Admins (NotFound für Nichtmitglieder)."""
server = get(session, server_id)
if server.workspace_id is None:
raise NotFound
if not workspaces.get(session, principal, server.workspace_id).is_admin:
raise Forbidden
return server
def set_headers(server: McpServer, vault: Vault, headers: dict[str, str]) -> None:
server.headers = vault.encrypt(json.dumps(headers)) if headers else None
server.header_names = sorted(headers)
def create_connection(session: Session, principal: Principal, settings: Settings, vault: Vault, workspace_id: int,
*, name: str, label: str, url: str, token: str = "", headers: str = "",
exposure_confirmed: bool = False) -> McpServer:
workspaces.require_admin(session, principal, workspace_id)
if not exposure_confirmed:
raise Invalid("Bitte bestätigen, dass alle Mitglieder des Workspace sehen dürfen, was dieser Server liefert.")
name, url, token, parsed = check_name(session, name), check_url(url, settings), check_token(token), \
check_headers(headers)
server = McpServer(workspace_id=workspace_id, name=name, label=label.strip()[:200] or name, url=url,
approval=APPROVED if approved_url(session, url) else PENDING,
exposure_confirmed_by=principal.user_id, exposure_confirmed_at=now(),
created_by=principal.user_id)
if token:
server.secret, server.secret_hint = vault.encrypt(token), token[-4:]
set_headers(server, vault, parsed)
session.add(server)
session.flush()
audit.record(session, "mcp.connection_created", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="mcp_server", target_id=server.id, name=name, url=url, approval=server.approval,
token=bool(token), headers=server.header_names)
audit.record(session, "source.exposure_confirmed", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="mcp_server", target_id=server.id)
if server.approval == PENDING:
request_approval(session, server)
return server
def update_connection(session: Session, principal: Principal, vault: Vault, server_id: int, *, label: str,
token: str = "", remove_token: bool = False, headers: str = "", remove_headers: bool = False,
active: bool = True) -> McpServer:
"""Bezeichnung, Token, Header und aktiv. Die URL bleibt: eine andere URL ist eine neue Verbindung."""
server = connection(session, principal, server_id)
token, parsed = check_token(token), check_headers(headers)
server.label, server.active = label.strip()[:200] or server.name, active
if token:
server.secret, server.secret_hint = vault.encrypt(token), token[-4:]
elif remove_token:
server.secret, server.secret_hint = None, ""
if parsed:
set_headers(server, vault, parsed)
elif remove_headers:
set_headers(server, vault, {})
if token or remove_token or parsed or remove_headers:
server.status, server.status_message = "new", "Geändert, bitte neu testen."
audit.record(session, "mcp.connection_updated", user_id=principal.user_id, workspace_id=server.workspace_id,
target_kind="mcp_server", target_id=server.id, active=active,
token="ersetzt" if token else "entfernt" if remove_token else "unverändert",
headers=server.header_names)
return server
def delete_connection(session: Session, principal: Principal, server_id: int) -> int:
server = connection(session, principal, server_id)
workspace_id = server.workspace_id
audit.record(session, "mcp.connection_deleted", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="mcp_server", target_id=server.id, name=server.name, url=server.url)
session.delete(server)
return workspace_id
def test_connection(session: Session, principal: Principal, settings: Settings, vault: Vault,
server_id: int) -> None:
"""Tools abrufen; bei Datenbanken zusätzlich die Verbindung zur Datenbank prüfen."""
server = connection(session, principal, server_id)
if server.approval != APPROVED:
server.status_message = "Wartet auf die Freigabe durch einen Plattform-Admin." \
if server.approval == PENDING else "Von einem Plattform-Admin abgelehnt."
return
if server.template_id:
from app.mcp import databases
databases.test(session, settings, vault, server, principal.user_id)
return
error, notes = refresh(session, settings, vault, server, principal.user_id)
count = len(tools_of(session, server.id))
server.status_message = error or f"{count} Tool{'s' if count != 1 else ''} gefunden." + (
" Übersprungen: " + " ".join(notes) if notes else "")
# --- Freigabe durch Plattform-Admins ---
def pending(session: Session) -> list[McpServer]:
return list(session.scalars(select(McpServer).where(McpServer.approval == PENDING).order_by(McpServer.created)))
def request_approval(session: Session, server: McpServer) -> None:
from app.notify import service as notify
from app.workspaces.models import Workspace
workspace = session.get(Workspace, server.workspace_id)
subject = f"MCP-Verbindung wartet auf Freigabe: {server.label} ({notify.clean(workspace.name, 80)})"
body = (f"Im Workspace „{workspace.name}“ wurde eine MCP-Verbindung zu einer neuen Adresse angelegt:\n\n"
f"{server.label} ({server.name})\n{server.url}\n\nErst nach der Freigabe nutzt Bifröst sie. Zur "
f"Entscheidung: {notify.link('/admin/mcp')}")
for admin in session.scalars(select(User).where(User.is_admin, User.active)):
notify.email(session, admin, "mcp.connection_requested", subject, body, workspace.id)
def decide(session: Session, principal: Principal, settings: Settings, vault: Vault, server_id: int,
approve: bool, note: str = "") -> McpServer:
"""Plattform-Admin gibt eine Verbindung frei (danach werden die Tools abgerufen) oder lehnt sie ab."""
require_platform_admin(principal)
server = get(session, server_id)
if server.workspace_id is None or server.approval != PENDING:
raise Invalid("Diese Verbindung wartet nicht auf eine Freigabe.")
server.approval, server.approval_note = (APPROVED if approve else REJECTED), note.strip()[:500]
audit.record(session, "mcp.connection_approved" if approve else "mcp.connection_rejected",
user_id=principal.user_id, workspace_id=server.workspace_id, target_kind="mcp_server",
target_id=server.id, url=server.url, note=server.approval_note)
if approve:
error, _ = refresh(session, settings, vault, server, principal.user_id)
count = len(tools_of(session, server.id))
server.status_message = error or f"Freigegeben, {count} Tool{'s' if count != 1 else ''} gefunden."
else:
server.status_message = "Abgelehnt" + (f": {server.approval_note}" if server.approval_note else ".")
return server