feat: MCP-Server für RAG-Retrieval + Webhook-Härtung
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:
@@ -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:
|
||||
|
||||
@@ -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
131
tests/test_mcp_server.py
Normal 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"}]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user