"""Gedächtnis über Munin (→ Konzept 7.6): Anleitung im Prompt, Einstufung, Ansicht und Löschen.""" import json import pytest from mcp.server.mcpserver import MCPServer from sqlalchemy import select from app.audit.models import AuditEvent from app.identity.models import User from app.mcp.models import McpServer from app.tasks.worker import Worker from app.tools.models import ToolDefinition from helpers import McpTestServer, connect_repo, create_workspace INSTRUCTIONS = "Munin-Anleitung: erst recall, dann remember. Nie Geheimnisse speichern." class FakeMunin: """Nachbau der Munin-Tools mit In-Memory-Einträgen.""" def __init__(self): self.entries = {"e1": "Staging-DB wird sonntags zurückgesetzt.", "e2": "Deployment nur dienstags."} self.forgotten = [] # die Tool-Funktion list überdeckt unten den eingebauten Namen server = MCPServer("munin", version="9.9", instructions=INSTRUCTIONS) @server.tool(description="Sucht Einträge.") def recall(query: str) -> str: return "\n".join(f"- {k}: {v}" for k, v in self.entries.items() if query.lower() in v.lower()) or "nichts" @server.tool(description="Liest einen Eintrag.") def get(id: str) -> str: return self.entries.get(id, "nicht gefunden") @server.tool(description="Listet Einträge.") def list() -> str: return json.dumps({"entries": [{"id": k, "text": v} for k, v in self.entries.items()]}) @server.tool(description="Verwandte Einträge.") def related(id: str) -> str: return "keine" @server.tool(description="Speichert einen Eintrag.") def remember(content: str) -> str: key = f"e{len(self.entries) + 1}" self.entries[key] = content return f"gespeichert als {key}" @server.tool(description="Löscht einen Eintrag.") def forget(id: str) -> str: self.forgotten.append(id) self.entries.pop(id, None) return "in den Papierkorb gelegt" self.http = McpTestServer(server) def __enter__(self): self.http.__enter__() return self def __exit__(self, *exc): self.http.__exit__(*exc) @pytest.fixture def munin(): with FakeMunin() as m: yield m @pytest.fixture def settings(settings): return settings.model_copy(update={"mcp_allow_local": True, "mcp_timeout_seconds": 5}) @pytest.fixture def setup(browser, people, db, munin, settings): with db.session() as session: session.get(User, people["anna"]).is_admin = True anna = browser("anna") response = anna.post("/admin/mcp", {"name": "munin", "label": "Munin", "url": munin.http.url, "role": "memory"}) server_id = int(response.headers["location"].rsplit("/", 1)[1]) page = anna.post(f"/admin/mcp/{server_id}/discover").text assert "Anleitung des Servers" in page and "Empfehlung übernehmen" in page page = anna.post(f"/admin/mcp/{server_id}/memory-defaults").text assert "6 Tools nach der Empfehlung für Munin eingestuft" in page workspace_id = create_workspace(anna, people["team_demo"]) anna.post(f"/workspaces/{workspace_id}/members", {"username": "ben", "role": "member"}) # Workspace-Key (in Munin bestimmt der Key das Projekt) assert anna.post(f"/workspaces/{workspace_id}/mcp/{server_id}", {"token": "munin-projekt-key-1"}).status_code == 303 return {"anna": anna, "ben": browser("ben"), "dora": browser("dora"), "workspace_id": workspace_id, "server_id": server_id} def test_empfohlene_einstufung(setup, db): with db.session() as session: found = {d.name: (d.risk_class, d.decision) for d in session.scalars( select(ToolDefinition).where(ToolDefinition.name.like("munin__%")))} server = session.scalar(select(McpServer)) assert found == {"munin__recall": ("read", "allow"), "munin__get": ("read", "allow"), "munin__list": ("read", "allow"), "munin__related": ("read", "allow"), "munin__remember": ("write_internal", "allow"), "munin__forget": ("destructive", "deny")} assert server.role == "memory" and server.server_info["instructions"] == INSTRUCTIONS def test_anleitung_im_prompt_und_forget_nie_fuer_die_ki(setup, db, settings, fake_provider, git_repo): connect_repo(setup["anna"], setup["workspace_id"], git_repo) chat_id = int(setup["ben"].post(f"/workspaces/{setup['workspace_id']}/chats").headers["location"].rsplit("/", 1)[1]) fake_provider.script = [[("munin__recall", {"query": "staging"}), ("munin__forget", {"id": "e1"})], "Erledigt."] setup["ben"].post(f"/chats/{chat_id}/messages", {"content": "Was gilt für Staging?"}, headers={"HX-Request": "true"}) Worker(db, settings).run_once() first = fake_provider.calls[0] assert INSTRUCTIONS in first["instructions"] and "Gedächtnis dieses Workspace" in first["instructions"] offered = {t["name"] for t in first["tools"]} assert "munin__recall" in offered and "munin__remember" in offered and "munin__forget" not in offered outputs = [i["output"] for i in fake_provider.calls[1]["input"] if i.get("type") == "function_call_output"] assert "Staging-DB" in outputs[0] and "nicht verfügbar" in outputs[1] def test_gedaechtnis_ansehen(setup, munin): ws = setup["workspace_id"] assert "Gedächtnis" in setup["ben"].get(f"/workspaces/{ws}").text page = setup["ben"].get(f"/workspaces/{ws}/memory").text assert "Staging-DB wird sonntags zurückgesetzt." in page and "?entry=e1" in page assert "Eintrag löschen" not in page page = setup["ben"].get(f"/workspaces/{ws}/memory?q=deployment").text assert "Deployment nur dienstags." in page and "Staging" not in page.split("Treffer")[-1] page = setup["ben"].get(f"/workspaces/{ws}/memory?entry=e2").text assert "Eintrag e2" in page assert setup["dora"].get(f"/workspaces/{ws}/memory").status_code == 404 def test_loeschen_nur_durch_admins(setup, db, munin): ws = setup["workspace_id"] assert setup["ben"].post(f"/workspaces/{ws}/memory/forget", {"entry_id": "e1"}).status_code == 403 assert munin.forgotten == [] page = setup["anna"].get(f"/workspaces/{ws}/memory?entry=e1").text assert "Eintrag löschen" in page response = setup["anna"].post(f"/workspaces/{ws}/memory/forget", {"entry_id": "e1"}) assert response.status_code == 200 and "Eintrag e1 gelöscht" in response.text assert munin.forgotten == ["e1"] and "Staging-DB" not in response.text assert setup["anna"].post(f"/workspaces/{ws}/memory/forget", {"entry_id": "a b"}).status_code == 400 with db.session() as session: event = session.scalar(select(AuditEvent).where(AuditEvent.action == "memory.forgotten")) assert event.target_id == "e1" and event.workspace_id == ws def test_ohne_gedaechtnis_server(browser, people, db): anna = browser("anna") ws = create_workspace(anna, people["team_demo"]) page = anna.get(f"/workspaces/{ws}/memory").text assert "kein Gedächtnis-Server freigeschaltet" in page assert ">Gedächtnis" not in anna.get(f"/workspaces/{ws}").text assert anna.post(f"/workspaces/{ws}/memory/forget", {"entry_id": "e1"}).status_code == 404 def test_unbekannte_art_abgelehnt(setup): response = setup["anna"].post("/admin/mcp", {"name": "x1", "label": "X", "url": "http://localhost:1/mcp", "role": "chef"}) assert response.status_code == 400 and "Unbekannte Art" in response.text