351 lines
20 KiB
Python
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
|