"""Zentrale MCP-Server (→ Konzept 7.1–7.3, Abnahmekriterien 15). Getestet wird gegen einen echten Streamable-HTTP-Server im selben Prozess (uvicorn im Thread), damit Transport, Bearer-Token und Fehlerfälle echt sind. """ import re import socket import threading import time import uuid import pytest import uvicorn from mcp.server.mcpserver import MCPServer from mcp.types import ToolAnnotations from sqlalchemy import select from app.approvals.models import ApprovalRequest from app.identity import Principal from app.identity.models import User from app.mcp.models import McpServer, McpTool from app.tasks import states from app.tasks.models import TaskRun from app.tasks.worker import Worker from app.tools import service as tools from app.tools.models import ALLOW, REQUIRE_APPROVAL, ToolCall, ToolDefinition from helpers import connect_repo, create_workspace class Remote: """Testserver: merkt sich Aufrufe und gesehene Tokens.""" def __init__(self, description: str = "Sucht im Gedächtnis."): self.calls: list[tuple[str, dict]] = [] self.tokens: list[str] = [] server = MCPServer("munin-test", version="1.0") @server.tool(description=description, annotations=ToolAnnotations(readOnlyHint=True)) def lookup(term: str) -> str: self.calls.append(("lookup", {"term": term})) return f"Treffer zu {term}: Port 8443" @server.tool(description="Speichert eine Notiz.") def remember(text: str) -> str: self.calls.append(("remember", {"text": text})) return "gespeichert" @server.tool(description="Geht immer schief.") def boom() -> str: raise ValueError("kaputt") app = server.streamable_http_app() async def guarded(scope, receive, send): if scope["type"] == "http": headers = dict(scope["headers"]) self.tokens.append(headers.get(b"authorization", b"").decode()) await app(scope, receive, send) with socket.socket() as s: s.bind(("127.0.0.1", 0)) self.port = s.getsockname()[1] self.server = uvicorn.Server(uvicorn.Config(guarded, host="127.0.0.1", port=self.port, log_level="warning", lifespan="on")) self.thread = threading.Thread(target=self.server.run, daemon=True) @property def url(self) -> str: return f"http://localhost:{self.port}/mcp" def __enter__(self) -> "Remote": self.thread.start() for _ in range(100): if self.server.started: return self time.sleep(0.05) raise RuntimeError("Testserver startet nicht") def __exit__(self, *exc) -> None: self.server.should_exit = True self.thread.join(5) @pytest.fixture def remote(): with Remote() as r: yield r @pytest.fixture def settings(settings): return settings.model_copy(update={"mcp_allow_local": True, "mcp_allowed_hosts": "mcp.intern", "mcp_timeout_seconds": 5}) @pytest.fixture def admin(browser, people, db): with db.session() as session: session.get(User, people["anna"]).is_admin = True return browser("anna") @pytest.fixture def principal(people): return Principal(user_id=people["anna"], username="anna", display_name="Anna", is_admin=True, team_ids=frozenset({people["team_demo"]})) def register(admin, remote, token: str = "server-token-1234") -> int: response = admin.post("/admin/mcp", {"name": "munin", "label": "Munin", "url": remote.url, "token": token}) assert response.status_code == 303, response.text server_id = int(response.headers["location"].rsplit("/", 1)[1]) page = admin.post(f"/admin/mcp/{server_id}/discover") assert page.status_code == 200, page.text return server_id @pytest.fixture def workspace(admin, people, db, git_repo, remote, principal): """Workspace mit Repo und freigeschaltetem MCP-Server; lookup ist erlaubt, remember braucht Freigabe.""" workspace_id = create_workspace(admin, people["team_demo"]) connect_repo(admin, workspace_id, git_repo) server_id = register(admin, remote) with db.session() as session: tools.sync(session) tools.classify(session, principal, "munin__lookup", "read", ALLOW) tools.classify(session, principal, "munin__remember", "write_internal", REQUIRE_APPROVAL) tools.classify(session, principal, "munin__boom", "read", ALLOW) assert admin.post(f"/workspaces/{workspace_id}/mcp/{server_id}").status_code == 303 chat_id = int(admin.post(f"/workspaces/{workspace_id}/chats").headers["location"].rsplit("/", 1)[1]) return {"id": workspace_id, "server_id": server_id, "chat_id": chat_id} def ask(b, chat_id: int, text: str = "Welcher Port?") -> uuid.UUID: response = b.post(f"/chats/{chat_id}/messages", {"content": text}, headers={"HX-Request": "true"}) return uuid.UUID(re.search(r"/runs/([0-9a-f-]+)/events", response.text).group(1)) def last_output(fake_provider) -> str: return [i for i in fake_provider.calls[-1]["input"] if i.get("type") == "function_call_output"][-1]["output"] def run_status(db, run_id) -> str: with db.session() as session: return session.get(TaskRun, run_id).status # --- Registrierung und Discovery --- def test_registrieren_und_tools_abrufen(admin, db, remote): server_id = register(admin, remote) with db.session() as session: server = session.get(McpServer, server_id) assert server.status == "ok" and server.server_info["name"] == "munin-test" assert "server-token" not in (server.secret or "") and server.secret_hint == "1234" rows = {t.name: t for t in session.scalars(select(McpTool))} assert set(rows) == {"munin__lookup", "munin__remember", "munin__boom"} assert rows["munin__lookup"].remote_name == "lookup" and rows["munin__lookup"].annotations["readOnlyHint"] assert "Bearer server-token-1234" in remote.tokens page = admin.get(f"/admin/mcp/{server_id}").text assert "readOnlyHint=True" in page and "Vorschlag: read (nicht geprüft)" in page # Annotations werden nie automatisch übernommen: "Vorschläge übernehmen" lässt MCP-Tools aus assert "munin__lookup" in admin.get("/admin/tools").text admin.post("/admin/tools/suggested") with db.session() as session: definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == "munin__lookup")) assert not definition.classified and definition.kind == "mcp:munin" @pytest.mark.parametrize("url, message", [ ("http://mcp.intern/mcp", "Nur https"), ("https://anderswo.example/mcp", "nicht freigegeben"), ("https://user:pw@mcp.intern/mcp", "Keine Zugangsdaten"), ]) def test_url_regeln(admin, url, message): response = admin.post("/admin/mcp", {"name": "x1", "label": "X", "url": url}) assert response.status_code == 400 and message in response.text def test_kurzname_und_berechtigung(admin, browser, remote): assert admin.post("/admin/mcp", {"name": "Munin!", "label": "X", "url": remote.url}).status_code == 400 assert admin.post("/admin/mcp", {"name": "a__b", "label": "X", "url": remote.url}).status_code == 400 ben = browser("ben") assert ben.get("/admin/mcp").status_code == 403 assert ben.post("/admin/mcp", {"name": "munin", "label": "X", "url": remote.url}).status_code == 403 def test_nicht_erreichbarer_server_beim_abrufen(admin, db): response = admin.post("/admin/mcp", {"name": "weg", "label": "Weg", "url": "http://localhost:9/mcp"}) server_id = int(response.headers["location"].rsplit("/", 1)[1]) page = admin.post(f"/admin/mcp/{server_id}/discover") assert page.status_code == 400 and "nicht erreichbar" in page.text with db.session() as session: assert session.get(McpServer, server_id).status == "error" def test_geaenderte_definition_wird_gesperrt(admin, db, principal): with Remote() as first: server_id = register(admin, first) with db.session() as session: tools.sync(session) tools.classify(session, principal, "munin__lookup", "read", ALLOW) # Derselbe Server meldet das Tool später mit anderer Beschreibung with Remote(description="Sucht und tut noch etwas anderes.") as changed: admin.post(f"/admin/mcp/{server_id}", {"label": "Munin", "url": changed.url, "active": "1"}) admin.post(f"/admin/mcp/{server_id}/discover") with db.session() as session: tools.sync(session) definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == "munin__lookup")) assert not definition.classified from app.workspaces.models import Workspace workspace = Workspace(team_id=1, name="x", created_by=1) assert tools.evaluate(session, workspace, "munin__lookup").decision == "deny" # --- Nutzung im Workspace --- def test_nur_freigeschaltet_und_eingestuft_wird_angeboten(workspace, admin, db, fake_provider, settings): admin.post(f"/workspaces/{workspace['id']}/mcp/{workspace['server_id']}/disable") ask(admin, workspace["chat_id"]) Worker(db, settings).run_once() offered = {t["name"] for t in fake_provider.calls[0]["tools"] or []} assert not any(n.startswith("munin__") for n in offered) admin.post(f"/workspaces/{workspace['id']}/mcp/{workspace['server_id']}") ask(admin, workspace["chat_id"], "Noch mal") Worker(db, settings).run_once() offered = {t["name"] for t in fake_provider.calls[1]["tools"]} assert {"munin__lookup", "munin__remember", "munin__boom"} <= offered assert "Tools von MCP-Diensten" in fake_provider.calls[1]["instructions"] def test_chat_ruft_mcp_tool(workspace, admin, db, fake_provider, settings, remote): fake_provider.script = [[("munin__lookup", {"term": "Port"})], "Port 8443."] run_id = ask(admin, workspace["chat_id"]) Worker(db, settings).run_once() assert run_status(db, run_id) == states.COMPLETED assert remote.calls == [("lookup", {"term": "Port"})] assert "Treffer zu Port: Port 8443" in last_output(fake_provider) with db.session() as session: call = session.scalar(select(ToolCall).where(ToolCall.run_id == run_id)) assert (call.tool, call.status) == ("munin__lookup", "ok") def test_workspace_token_hat_vorrang(workspace, admin, db, fake_provider, settings, remote): admin.post(f"/workspaces/{workspace['id']}/mcp/{workspace['server_id']}/disable") admin.post(f"/workspaces/{workspace['id']}/mcp/{workspace['server_id']}", {"token": "workspace-key-5678"}) assert "eigenes Token …5678" in admin.get(f"/workspaces/{workspace['id']}/settings?tab=mcp").text remote.tokens.clear() fake_provider.script = [[("munin__lookup", {"term": "x"})], "Ok."] ask(admin, workspace["chat_id"]) Worker(db, settings).run_once() assert remote.tokens and set(remote.tokens) == {"Bearer workspace-key-5678"} def test_argumente_werden_gegen_schema_geprueft(workspace, admin, db, fake_provider, settings, remote): fake_provider.script = [[("munin__lookup", {"suchwort": "x"})], "Ok."] ask(admin, workspace["chat_id"]) Worker(db, settings).run_once() assert "Ungültige Argumente" in last_output(fake_provider) and remote.calls == [] def test_tool_fehler_und_nicht_erreichbar_bleiben_kontrolliert(workspace, admin, db, fake_provider, settings, remote): fake_provider.script = [[("munin__boom", {})], "Ging nicht."] run_id = ask(admin, workspace["chat_id"]) Worker(db, settings).run_once() assert run_status(db, run_id) == states.COMPLETED assert "Das Tool meldet einen Fehler" in last_output(fake_provider) remote.__exit__() fake_provider.script = [[("munin__lookup", {"term": "x"})], "Server weg."] run_id = ask(admin, workspace["chat_id"], "Und jetzt?") Worker(db, settings).run_once() assert run_status(db, run_id) == states.COMPLETED assert "nicht erreichbar" in last_output(fake_provider) def test_mcp_tool_mit_freigabe(workspace, admin, db, fake_provider, settings, remote, browser): fake_provider.script = [[("munin__remember", {"text": "Port ist 8443"})], "Gemerkt."] run_id = ask(admin, workspace["chat_id"], "Merk dir den Port") Worker(db, settings).run_once() assert run_status(db, run_id) == states.WAITING_APPROVAL and remote.calls == [] with db.session() as session: approval = session.scalar(select(ApprovalRequest)) assert approval.source_id is None and approval.preconditions == {"server": "munin", "tool": "remember"} page = admin.get(f"/approvals/{approval.id}").text assert "Port ist 8443" in page and "an munin gesendet" in page admin.post(f"/approvals/{approval.id}/decide", {"decision": "approve"}) Worker(db, settings).run_once() assert run_status(db, run_id) == states.COMPLETED assert remote.calls == [("remember", {"text": "Port ist 8443"})] assert last_output(fake_provider) == '\ngespeichert\n' def test_inaktiver_server_bietet_nichts_an(workspace, admin, db): admin.post(f"/admin/mcp/{workspace['server_id']}", {"label": "Munin", "url": "http://localhost:1/mcp"}) with db.session() as session: assert not session.get(McpServer, workspace["server_id"]).active assert not any(n.startswith("munin__") for n in tools.catalog(session)) def test_nur_workspace_admin_schaltet_frei(workspace, admin, browser, people): admin.post(f"/workspaces/{workspace['id']}/members", {"username": "ben", "role": "member"}) ben = browser("ben") assert ben.post(f"/workspaces/{workspace['id']}/mcp/{workspace['server_id']}/disable").status_code == 403 assert browser("dora").post(f"/workspaces/{workspace['id']}/mcp/{workspace['server_id']}").status_code == 404 def test_freischaltung_ist_sichtbar(workspace, admin, db, fake_provider, settings): admin.post(f"/workspaces/{workspace['id']}/mcp/{workspace['server_id']}/disable") page = admin.get(f"/workspaces/{workspace['id']}/settings?tab=mcp").text assert "1 Server ist verfügbar, aber in diesem Workspace noch nicht freigeschaltet" in page assert "MCP-Server im Workspace nicht freigeschaltet" in admin.get(f"/workspaces/{workspace['id']}/settings?tab=tools").text assert "MCP:" not in admin.get(f"/chats/{workspace['chat_id']}").text admin.post(f"/workspaces/{workspace['id']}/mcp/{workspace['server_id']}") assert "noch nicht freigeschaltet" not in admin.get(f"/workspaces/{workspace['id']}/settings?tab=mcp").text assert 'badge-info">Munin<' in admin.get(f"/chats/{workspace['chat_id']}").text # Ohne Tool keine erfundenen Bestätigungen (→ Konzept 8.5) ask(admin, workspace["chat_id"], "Merk dir das") Worker(db, settings).run_once() assert "Behaupte nie, etwas gespeichert" in fake_provider.calls[-1]["instructions"]