321 lines
15 KiB
Python
321 lines
15 KiB
Python
"""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 <code>munin</code> 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) == '<fremdinhalt quelle="munin__remember">\ngespeichert\n</fremdinhalt>'
|
||
|
||
|
||
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"]
|