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