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