feat: MCP-Server für RAG-Retrieval + Webhook-Härtung
All checks were successful
CI / ci (push) Successful in 49s
Release / release (push) Successful in 1m2s

app/mcp_server.py: FastMCP (mcp SDK), streamable-http auf /mcp, statischer
Bearer-Token (constant-time ASGI-Middleware), Fail-Fast ohne RAG_MCP_TOKEN.
Tools rag_search (mit semester/fach/typ-Filter) + get_file_chunks. Läuft aus
demselben Image wie der Ingestor und reused den Embed-Pfad → Vektoren sind
garantiert kompatibel zum Ingest (der offizielle qdrant-MCP-Server kann nur
fastembed → Dimension-/Schema-Mismatch).

app/qdrant_store.py: search_chunks (query_points + optionaler Payload-Filter)
und get_chunks_by_path (scroll, nach chunk_index sortiert).

app/bulk.py: Amplification-Guard — /bulk-import lehnt mit 409 ab solange ein
vorheriger Bulk noch BackgroundTasks abarbeitet.

docker-compose.coolify.yml: rag-mcp-Service (nicht public, externes
metamcp-net statt Stack-Coupling) + Traefik-Rate-Limit-Middleware am ingestor.

tests/conftest.py: Settings-env_file in Tests neutralisieren (Dev-.env darf
die Suite nicht kontaminieren). 68 passed, ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 22:08:37 +02:00
parent a6a2175f8b
commit 9643011e64
12 changed files with 935 additions and 8 deletions

View File

@@ -3,6 +3,19 @@ import pytest
import fitz # pymupdf
from docx import Document
from app.config import Settings
@pytest.fixture(autouse=True)
def _ignore_dotenv():
"""Tests must be deterministic regardless of a developer .env in the repo
root. Settings reads env_file='.env'; neutralise it so tests see only the
environment they explicitly set (e.g. via monkeypatch)."""
original = Settings.model_config.get("env_file")
Settings.model_config["env_file"] = None
yield
Settings.model_config["env_file"] = original
@pytest.fixture
def sample_pdf_bytes() -> bytes:

View File

@@ -62,3 +62,21 @@ def test_bulk_import_rejects_wrong_secret(monkeypatch):
with TestClient(app) as client:
r = client.post("/bulk-import", json={"path": "x"}, headers={"X-Webhook-Secret": "nope"})
assert r.status_code == 401
def test_bulk_import_rejects_while_in_progress(monkeypatch):
app = _make_app(monkeypatch)
# Simulate an in-flight bulk: the amplification guard must reject.
monkeypatch.setattr("app.bulk._inflight", 4)
list_mock = AsyncMock()
monkeypatch.setattr("app.bulk.list_files_recursive", list_mock)
with TestClient(app) as client:
r = client.post(
"/bulk-import", json={"path": "x"}, headers={"X-Webhook-Secret": "abc"}
)
assert r.status_code == 409
# Guard fires before any WebDAV listing happens.
list_mock.assert_not_awaited()

131
tests/test_mcp_server.py Normal file
View File

@@ -0,0 +1,131 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
def _env(monkeypatch, *, token="tok"):
monkeypatch.setenv("NEXTCLOUD_WEBDAV_URL", "http://nc")
monkeypatch.setenv("NEXTCLOUD_USER", "u")
monkeypatch.setenv("NEXTCLOUD_APP_PASSWORD", "p")
monkeypatch.setenv("OLLAMA_URL", "http://ollama")
monkeypatch.setenv("OLLAMA_EMBED_MODEL", "qwen3-embedding:0.6b")
monkeypatch.setenv("QDRANT_URL", "http://qdrant")
monkeypatch.setenv("QDRANT_COLLECTION", "rag_test")
monkeypatch.setenv("WEBHOOK_SECRET", "s")
if token is not None:
monkeypatch.setenv("RAG_MCP_TOKEN", token)
from app.config import get_settings
get_settings.cache_clear()
async def _drain(app, scope):
sent = []
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(msg):
sent.append(msg)
await app(scope, receive, send)
return sent
def _http_scope(auth_header: bytes | None):
headers = [(b"authorization", auth_header)] if auth_header is not None else []
return {"type": "http", "headers": headers}
async def test_middleware_rejects_missing_token(monkeypatch):
from app.mcp_server import BearerAuthMiddleware
inner = AsyncMock()
mw = BearerAuthMiddleware(inner, "secret")
sent = await _drain(mw, _http_scope(None))
assert sent[0]["status"] == 401
inner.assert_not_awaited()
async def test_middleware_rejects_wrong_token(monkeypatch):
from app.mcp_server import BearerAuthMiddleware
inner = AsyncMock()
mw = BearerAuthMiddleware(inner, "secret")
sent = await _drain(mw, _http_scope(b"Bearer nope"))
assert sent[0]["status"] == 401
inner.assert_not_awaited()
async def test_middleware_passes_correct_token():
from app.mcp_server import BearerAuthMiddleware
inner = AsyncMock()
mw = BearerAuthMiddleware(inner, "secret")
await _drain(mw, _http_scope(b"Bearer secret"))
inner.assert_awaited_once()
async def test_middleware_passes_through_non_http_scope():
from app.mcp_server import BearerAuthMiddleware
inner = AsyncMock()
mw = BearerAuthMiddleware(inner, "secret")
await _drain(mw, {"type": "lifespan"})
inner.assert_awaited_once()
def test_build_app_exits_without_token(monkeypatch):
_env(monkeypatch, token=None)
import app.mcp_server as m
with pytest.raises(SystemExit):
m.build_app()
async def test_rag_search_embeds_query_and_forwards_filters(monkeypatch):
_env(monkeypatch)
import app.mcp_server as m
embed = AsyncMock(return_value=[[0.5] * 4])
search = MagicMock(return_value=[{"text": "hit", "score": 0.9}])
monkeypatch.setattr(m, "embed_texts", embed)
monkeypatch.setattr(m, "search_chunks", search)
monkeypatch.setattr(m, "_qdrant", lambda: "CLIENT")
out = await m.rag_search("was ist BPMN?", limit=3, semester="2.Semester")
embed.assert_awaited_once_with(["was ist BPMN?"], model="qwen3-embedding:0.6b")
args, kwargs = search.call_args
assert args[0] == "CLIENT"
assert args[1] == "rag_test"
assert args[2] == [0.5] * 4
assert kwargs == {
"limit": 3,
"semester": "2.Semester",
"fach": None,
"typ": None,
}
assert out == [{"text": "hit", "score": 0.9}]
async def test_get_file_chunks_delegates(monkeypatch):
_env(monkeypatch)
import app.mcp_server as m
getter = MagicMock(return_value=[{"chunk_index": 0, "text": "x"}])
monkeypatch.setattr(m, "get_chunks_by_path", getter)
monkeypatch.setattr(m, "_qdrant", lambda: "CLIENT")
out = await m.get_file_chunks("Documents/x.pdf")
getter.assert_called_once_with("CLIENT", "rag_test", "Documents/x.pdf")
assert out == [{"chunk_index": 0, "text": "x"}]

View File

@@ -5,6 +5,9 @@ from app.qdrant_store import (
ensure_collection,
upsert_chunks,
delete_by_path,
search_chunks,
get_chunks_by_path,
_payload_filter,
ChunkPoint,
)
@@ -79,3 +82,86 @@ def test_delete_by_path_uses_filter():
selector = kwargs["points_selector"]
# Inspect the FilterSelector → Filter → must → FieldCondition
assert selector.filter.must[0].key == "file_path"
def test_payload_filter_none_when_no_constraints():
assert _payload_filter(None, None, None) is None
def test_payload_filter_builds_only_given_conditions():
flt = _payload_filter(semester="2.Semester", fach=None, typ="Vorlesungen")
keys = [c.key for c in flt.must]
assert keys == ["semester", "typ"]
assert flt.must[0].match.value == "2.Semester"
assert flt.must[1].match.value == "Vorlesungen"
def test_search_chunks_maps_payload_and_score():
hit = MagicMock()
hit.payload = {
"text": "chunk text",
"file_path": "Documents/THB/2.Semester/Databases/a.pdf",
"file_name": "a.pdf",
"semester": "2.Semester",
"fach": "Databases",
"typ": "Vorlesungen",
"page": 3,
"chunk_index": 2,
"ignored": "not in result fields",
}
hit.score = 0.87
response = MagicMock()
response.points = [hit]
fake_client = MagicMock()
fake_client.query_points.return_value = response
out = search_chunks(
fake_client, "rag_test", [0.1] * 4, limit=5, fach="Databases"
)
kwargs = fake_client.query_points.call_args.kwargs
assert kwargs["collection_name"] == "rag_test"
assert kwargs["limit"] == 5
assert kwargs["query_filter"].must[0].key == "fach"
assert out == [
{
"text": "chunk text",
"file_path": "Documents/THB/2.Semester/Databases/a.pdf",
"file_name": "a.pdf",
"semester": "2.Semester",
"fach": "Databases",
"typ": "Vorlesungen",
"page": 3,
"chunk_index": 2,
"score": 0.87,
}
]
def test_search_chunks_no_filter_passes_none():
response = MagicMock()
response.points = []
fake_client = MagicMock()
fake_client.query_points.return_value = response
search_chunks(fake_client, "rag_test", [0.1] * 4, limit=3)
assert fake_client.query_points.call_args.kwargs["query_filter"] is None
def test_get_chunks_by_path_sorts_by_chunk_index():
def pt(idx, page, text):
m = MagicMock()
m.payload = {"chunk_index": idx, "page": page, "text": text}
return m
fake_client = MagicMock()
fake_client.scroll.return_value = ([pt(2, 1, "c"), pt(0, 1, "a"), pt(1, 1, "b")], None)
rows = get_chunks_by_path(fake_client, "rag_test", "Documents/x.pdf")
assert [r["chunk_index"] for r in rows] == [0, 1, 2]
assert [r["text"] for r in rows] == ["a", "b", "c"]
scroll_kwargs = fake_client.scroll.call_args.kwargs
assert scroll_kwargs["scroll_filter"].must[0].key == "file_path"
assert scroll_kwargs["with_vectors"] is False