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

351 lines
20 KiB
Python

"""MCP-Server und Datenbank-Treiber verwalten, Verbindungen freigeben (Plattform-Admin); zentrale Server
freischalten sowie eigene Verbindungen und Datenbanken anlegen (Workspace-Admin)."""
from typing import Annotated
from fastapi import APIRouter, Form, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import HTMLResponse, Response
from sqlalchemy import select
from app import web
from app.core.errors import Invalid, NotFound
from app.identity.admin import require as require_platform_admin
from app.mcp import databases, memory
from app.mcp import service as mcp
from app.mcp.models import APPROVED
from app.tools.models import ToolDefinition
from app.tools.registry import ToolError
from app.workspaces import service as workspaces
from app.workspaces.models import Workspace
Text = Annotated[str, Form()]
Flag = Annotated[bool, Form()]
def router() -> APIRouter:
r = APIRouter()
def overview_page(request: Request, principal, error: str = "", form: dict | None = None,
status: int = 200) -> HTMLResponse:
require_platform_admin(principal)
with web.db(request).session() as session:
names = dict(session.execute(select(Workspace.id, Workspace.name)).all())
context = {"servers": mcp.servers(session), "error": error, "form": form or {},
"vault_ready": web.vault(request).ready, "roles": mcp.SERVER_ROLES,
"pending": mcp.pending(session),
"connections": [c for c in mcp.connections(session) if not c.template_id],
"workspace_names": names}
return web.render(request, "admin_mcp.html", context, status)
def drivers_page(request: Request, principal, error: str = "", form: dict | None = None,
status: int = 200) -> HTMLResponse:
require_platform_admin(principal)
with web.db(request).session() as session:
names = dict(session.execute(select(Workspace.id, Workspace.name)).all())
databases_ = {}
for c in mcp.connections(session):
if c.template_id:
databases_.setdefault(c.template_id, []).append(c)
context = {"drivers": mcp.drivers(session), "databases": databases_, "workspace_names": names,
"error": error, "form": form or {}, "vault_ready": web.vault(request).ready}
return web.render(request, "admin_databases.html", context, status)
def server_page(request: Request, principal, server_id: int, error: str = "", notice: str = "",
notes: list[str] | None = None, status: int = 200) -> HTMLResponse:
require_platform_admin(principal)
with web.db(request).session() as session:
server = mcp.get(session, server_id)
rows = mcp.tools_of(session, server_id)
definitions = {d.name: d for d in session.scalars(
select(ToolDefinition).where(ToolDefinition.name.in_([t.name for t in rows])))}
context = {"server": server, "tools": rows, "definitions": definitions, "suggest": mcp.suggest,
"error": error, "notice": notice, "notes": notes or [], "roles": mcp.SERVER_ROLES,
"uses": mcp.uses(session, server_id),
"memory_defaults": memory.DEFAULTS}
return web.render(request, "admin_mcp_server.html", context, status)
@r.get("/admin/mcp")
def overview(request: Request, principal: web.User) -> HTMLResponse:
return overview_page(request, principal)
@r.post("/admin/mcp")
def create(request: Request, principal: web.Checked, name: Text, label: Text, url: Text,
token: Text = "", role: Text = "") -> Response:
try:
with web.db(request).session() as session:
server = mcp.save(session, principal, web.settings(request), web.vault(request), None,
name=name, label=label, url=url, token=token, role=role)
server_id = server.id
except Invalid as e:
return overview_page(request, principal, str(e), {"name": name, "label": label, "url": url,
"role": role}, 400)
return web.redirect(f"/admin/mcp/{server_id}")
@r.get("/admin/databases")
def drivers(request: Request, principal: web.User) -> HTMLResponse:
return drivers_page(request, principal)
@r.post("/admin/databases")
def create_driver(request: Request, principal: web.Checked, name: Text, label: Text, url: Text,
token: Text = "") -> Response:
try:
with web.db(request).session() as session:
server = mcp.save(session, principal, web.settings(request), web.vault(request), None,
name=name, label=label, url=url, token=token, role=mcp.DATABASE)
server_id = server.id
except Invalid as e:
return drivers_page(request, principal, str(e), {"name": name, "label": label, "url": url}, 400)
return web.redirect(f"/admin/mcp/{server_id}")
@r.get("/admin/mcp/{server_id}")
def detail(request: Request, principal: web.User, server_id: int) -> HTMLResponse:
return server_page(request, principal, server_id)
@r.post("/admin/mcp/{server_id}")
def update(request: Request, principal: web.Checked, server_id: int, label: Text, url: Text,
token: Text = "", remove_token: Flag = False, active: Flag = False, role: Text = "") -> Response:
try:
with web.db(request).session() as session:
mcp.save(session, principal, web.settings(request), web.vault(request), server_id, name="",
label=label, url=url, token=token, remove_token=remove_token, active=active, role=role)
except Invalid as e:
return server_page(request, principal, server_id, error=str(e), status=400)
return server_page(request, principal, server_id, notice="Gespeichert.")
@r.post("/admin/mcp/{server_id}/documents")
def documents_config(request: Request, principal: web.Checked, server_id: int, tool: Text, content: Text,
filename: Text = "") -> Response:
try:
with web.db(request).session() as session:
mcp.set_documents(session, principal, server_id, tool, content, filename)
except Invalid as e:
return server_page(request, principal, server_id, error=str(e), status=400)
return server_page(request, principal, server_id, notice="Dokumenten-Auslese gespeichert.")
@r.post("/admin/mcp/{server_id}/discover")
async def discover(request: Request, principal: web.Checked, server_id: int) -> Response:
def work() -> tuple[str, list[str]]:
with web.db(request).session() as session:
return mcp.discover(session, principal, web.settings(request), web.vault(request), server_id)
error, notes = await run_in_threadpool(work)
if error:
return server_page(request, principal, server_id, error=error, status=400)
return server_page(request, principal, server_id, notes=notes,
notice="Tools abgerufen. Neue oder geänderte Tools sind gesperrt, bis sie unter "
"„Tools“ eingestuft sind.")
@r.post("/admin/mcp/{server_id}/memory-defaults")
def memory_defaults(request: Request, principal: web.Checked, server_id: int) -> Response:
require_platform_admin(principal)
with web.db(request).session() as session:
count = memory.apply_defaults(session, principal, server_id)
return server_page(request, principal, server_id,
notice=f"{count} Tools nach der Empfehlung für Munin eingestuft.")
@r.get("/workspaces/{workspace_id}/memory")
async def memory_page(request: Request, principal: web.User, workspace_id: int, q: str = "",
entry: str = "") -> HTMLResponse:
return await run_in_threadpool(show_memory, request, principal, workspace_id, q, entry)
@r.post("/workspaces/{workspace_id}/memory/forget")
async def memory_forget(request: Request, principal: web.Checked, workspace_id: int,
entry_id: Text) -> HTMLResponse:
def work() -> HTMLResponse:
db, settings, vault = web.db(request), web.settings(request), web.vault(request)
with db.session() as session:
workspaces.require_admin(session, principal, workspace_id)
found = memory.for_workspace(session, vault, settings, workspace_id)
if found is None:
raise NotFound
try:
with db.session() as session:
output = memory.forget(session, principal, found, workspace_id, entry_id)
except (Invalid, ToolError) as e:
return show_memory(request, principal, workspace_id, error=str(e), status=400)
return show_memory(request, principal, workspace_id, notice=f"Eintrag {entry_id} gelöscht. {output}"[:500])
return await run_in_threadpool(work)
def show_memory(request: Request, principal, workspace_id: int, q: str = "", entry: str = "",
error: str = "", notice: str = "", status: int = 200) -> HTMLResponse:
db, settings, vault = web.db(request), web.settings(request), web.vault(request)
with db.session() as session:
membership = workspaces.get(session, principal, workspace_id)
found = memory.for_workspace(session, vault, settings, workspace_id)
context = {"workspace": membership.workspace, "current_workspace": membership.workspace,
"membership": membership, "memory": found, "q": q, "entry_id": entry, "error": error,
"notice": notice, "text": "", "items": None, "entry_text": ""}
if found is not None:
try:
if entry:
context["entry_text"] = memory.entry(found, entry)
context["text"] = memory.entries(found, q)
context["items"] = memory.items(context["text"])
except (Invalid, ToolError) as e:
context["error"], status = str(e), status if status != 200 else 502
return web.render(request, "memory.html", context, status)
@r.post("/admin/mcp/{server_id}/delete")
def delete(request: Request, principal: web.Checked, server_id: int) -> Response:
try:
with web.db(request).session() as session:
driver = mcp.get(session, server_id).role == mcp.DATABASE
mcp.delete(session, principal, server_id)
except Invalid as e:
return server_page(request, principal, server_id, error=str(e), status=400)
return web.redirect("/admin/databases" if driver else "/admin/mcp")
@r.post("/workspaces/{workspace_id}/mcp/{server_id}")
def enable(request: Request, principal: web.Checked, workspace_id: int, server_id: int,
token: Text = "") -> Response:
with web.db(request).session() as session:
mcp.enable(session, principal, web.vault(request), workspace_id, server_id, token)
return web.redirect(f"/workspaces/{workspace_id}/settings?tab=mcp&saved=1")
@r.post("/workspaces/{workspace_id}/mcp/{server_id}/disable")
def disable(request: Request, principal: web.Checked, workspace_id: int, server_id: int) -> Response:
with web.db(request).session() as session:
mcp.disable(session, principal, workspace_id, server_id)
return web.redirect(f"/workspaces/{workspace_id}/settings?tab=mcp&saved=1")
@r.post("/admin/mcp/{server_id}/approve")
def approve(request: Request, principal: web.Checked, server_id: int, note: Text = "") -> Response:
with web.db(request).session() as session:
mcp.decide(session, principal, web.settings(request), web.vault(request), server_id, True, note)
return web.redirect("/admin/mcp")
@r.post("/admin/mcp/{server_id}/reject")
def reject(request: Request, principal: web.Checked, server_id: int, note: Text = "") -> Response:
with web.db(request).session() as session:
mcp.decide(session, principal, web.settings(request), web.vault(request), server_id, False, note)
return web.redirect("/admin/mcp")
# --- Verbindungen eines Workspace ---
def connection_form(request: Request, principal, workspace_id: int, form: dict, error: str = "",
status: int = 200, server=None) -> HTMLResponse:
with web.db(request).session() as session:
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
return web.render(request, "connection_form.html", {
"workspace": workspace, "current_workspace": workspace, "form": form, "error": error,
"server": server}, status)
def database_form(request: Request, principal, workspace_id: int, form: dict, error: str = "",
status: int = 200, server=None) -> HTMLResponse:
with web.db(request).session() as session:
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
drivers_ = databases.drivers(session)
driver = session.get(type(server), server.template_id) if server is not None else None
return web.render(request, "database_form.html", {
"workspace": workspace, "current_workspace": workspace, "form": form, "error": error,
"drivers": drivers_, "server": server, "driver": driver, "ssl_modes": databases.SSL_MODES,
"allow_local": web.settings(request).db_allow_local}, status)
def back(workspace_id: int) -> Response:
return web.redirect(f"/workspaces/{workspace_id}/settings?tab=mcp&saved=1#connections")
def run_test(request: Request, principal, server_id: int) -> None:
with web.db(request).session() as session:
mcp.test_connection(session, principal, web.settings(request), web.vault(request), server_id)
@r.get("/workspaces/{workspace_id}/connections/new")
def new_connection(request: Request, principal: web.User, workspace_id: int) -> HTMLResponse:
return connection_form(request, principal, workspace_id, {})
@r.post("/workspaces/{workspace_id}/connections")
def create_connection(request: Request, principal: web.Checked, workspace_id: int, name: Text, url: Text,
label: Text = "", token: Text = "", headers: Text = "",
exposure_confirmed: Flag = False) -> Response:
try:
with web.db(request).session() as session:
server = mcp.create_connection(session, principal, web.settings(request), web.vault(request),
workspace_id, name=name, label=label, url=url, token=token,
headers=headers, exposure_confirmed=exposure_confirmed)
server_id, approved = server.id, server.approval == APPROVED
except Invalid as e:
# Token und Header werden nicht zurück ins Formular geschrieben
form = {"name": name, "label": label, "url": url, "exposure_confirmed": exposure_confirmed}
return connection_form(request, principal, workspace_id, form, str(e), 400)
if approved:
run_test(request, principal, server_id)
return back(workspace_id)
@r.get("/workspaces/{workspace_id}/databases/new")
def new_database(request: Request, principal: web.User, workspace_id: int) -> HTMLResponse:
return database_form(request, principal, workspace_id, {})
@r.post("/workspaces/{workspace_id}/databases")
def create_database(request: Request, principal: web.Checked, workspace_id: int, driver_id: Text, name: Text,
host: Text, database: Text, user: Text, label: Text = "", port: Text = "",
password: Text = "", sslmode: Text = "require", exposure_confirmed: Flag = False) -> Response:
try:
with web.db(request).session() as session:
server = databases.create(session, principal, web.settings(request), web.vault(request),
workspace_id, driver_id=driver_id, name=name, label=label, host=host,
port=port, database=database, user=user, password=password,
sslmode=sslmode, exposure_confirmed=exposure_confirmed)
server_id = server.id
except Invalid as e:
# Das Passwort wird nicht zurück ins Formular geschrieben
form = {"driver_id": driver_id, "name": name, "label": label, "host": host, "port": port,
"database": database, "user": user, "sslmode": sslmode, "exposure_confirmed": exposure_confirmed}
return database_form(request, principal, workspace_id, form, str(e), 400)
run_test(request, principal, server_id)
return back(workspace_id)
@r.get("/connections/{server_id}/edit")
def edit_connection(request: Request, principal: web.User, server_id: int, error: str = "",
status: int = 200) -> HTMLResponse:
with web.db(request).session() as session:
server = mcp.connection(session, principal, server_id)
session.expunge(server)
if server.template_id:
return database_form(request, principal, server.workspace_id, dict(server.config), error, status, server)
return connection_form(request, principal, server.workspace_id, {}, error, status, server)
@r.post("/connections/{server_id}")
def update_connection(request: Request, principal: web.Checked, server_id: int, label: Text = "",
token: Text = "", remove_token: Flag = False, headers: Text = "",
remove_headers: Flag = False, active: Flag = False) -> Response:
try:
with web.db(request).session() as session:
server = mcp.update_connection(session, principal, web.vault(request), server_id, label=label,
token=token, remove_token=remove_token, headers=headers,
remove_headers=remove_headers, active=active)
workspace_id, approved = server.workspace_id, server.approval == APPROVED
except Invalid as e:
return edit_connection(request, principal, server_id, str(e), 400)
if approved and active:
run_test(request, principal, server_id)
return back(workspace_id)
@r.post("/connections/{server_id}/database")
def update_database(request: Request, principal: web.Checked, server_id: int, user: Text, label: Text = "",
password: Text = "", remove_password: Flag = False, sslmode: Text = "require",
active: Flag = False) -> Response:
try:
with web.db(request).session() as session:
server = databases.update(session, principal, web.settings(request), web.vault(request), server_id,
label=label, user=user, password=password,
remove_password=remove_password, sslmode=sslmode, active=active)
workspace_id = server.workspace_id
except Invalid as e:
return edit_connection(request, principal, server_id, str(e), 400)
if active:
run_test(request, principal, server_id)
return back(workspace_id)
@r.post("/connections/{server_id}/test")
def test_connection(request: Request, principal: web.Checked, server_id: int) -> Response:
with web.db(request).session() as session:
workspace_id = mcp.connection(session, principal, server_id).workspace_id
run_test(request, principal, server_id)
return back(workspace_id)
@r.post("/connections/{server_id}/delete")
def delete_connection(request: Request, principal: web.Checked, server_id: int) -> Response:
with web.db(request).session() as session:
workspace_id = mcp.delete_connection(session, principal, server_id)
return back(workspace_id)
return r