175 lines
5.5 KiB
Python
175 lines
5.5 KiB
Python
"""
|
|
End-to-end test for the MCP server: build a real index, then invoke
|
|
handle_request(tools/call → leann_search) and verify JSON results come back
|
|
through the full MCP → subprocess → leann CLI pipeline.
|
|
|
|
Requires the embedding model and `leann` on PATH (satisfied by uv run pytest).
|
|
Marked 'slow'.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "packages" / "leann-core" / "src"))
|
|
|
|
pytestmark = [
|
|
pytest.mark.slow,
|
|
pytest.mark.skipif(os.environ.get("CI") == "true", reason="Skip in CI — needs model download"),
|
|
pytest.mark.skipif(not shutil.which("leann"), reason="leann CLI not on PATH"),
|
|
]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def mcp_index(tmp_path_factory):
|
|
"""Build a real LEANN index once for the entire module."""
|
|
from leann.cli import LeannCLI
|
|
|
|
tmp = tmp_path_factory.mktemp("mcp_e2e")
|
|
fixtures = Path(__file__).parent / "fixtures"
|
|
docs = tmp / "docs"
|
|
shutil.copytree(fixtures, docs)
|
|
|
|
cli = LeannCLI()
|
|
cli.indexes_dir = tmp / ".leann" / "indexes"
|
|
cli.indexes_dir.mkdir(parents=True)
|
|
|
|
parser = cli.create_parser()
|
|
args = parser.parse_args(
|
|
[
|
|
"build",
|
|
"openclaw-memory",
|
|
"--docs",
|
|
str(docs),
|
|
"--backend-name",
|
|
"hnsw",
|
|
"--no-compact",
|
|
"--embedding-model",
|
|
"all-MiniLM-L6-v2",
|
|
"--embedding-mode",
|
|
"sentence-transformers",
|
|
]
|
|
)
|
|
asyncio.get_event_loop().run_until_complete(cli.build_index(args))
|
|
|
|
index_dir = cli.indexes_dir / "openclaw-memory"
|
|
assert (index_dir / "documents.leann.meta.json").exists(), "Index build failed"
|
|
return index_dir
|
|
|
|
|
|
def _project_root(mcp_index):
|
|
"""The temp dir that contains .leann/indexes/ — used as cwd for subprocess.
|
|
|
|
mcp_index = tmp/.leann/indexes/openclaw-memory
|
|
We need tmp (3 parents up) so LeannCLI finds .leann/indexes/.
|
|
"""
|
|
return str(mcp_index.parent.parent.parent)
|
|
|
|
|
|
def test_mcp_search_via_subprocess(mcp_index):
|
|
"""Invoke `leann search --json` as a subprocess (same as MCP server does)."""
|
|
leann_bin = shutil.which("leann")
|
|
assert leann_bin is not None, "leann not found on PATH"
|
|
|
|
cmd = [
|
|
leann_bin,
|
|
"search",
|
|
"openclaw-memory",
|
|
"gRPC migration benchmarks",
|
|
"--top-k=3",
|
|
"--complexity=32",
|
|
"--non-interactive",
|
|
"--json",
|
|
]
|
|
cwd = _project_root(mcp_index)
|
|
result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
|
|
assert result.returncode == 0, f"leann search failed (cwd={cwd}): {result.stderr[:500]}"
|
|
assert result.stdout.strip(), f"No output from leann search (stderr: {result.stderr[:500]})"
|
|
|
|
results = json.loads(result.stdout)
|
|
assert isinstance(results, list)
|
|
assert len(results) > 0
|
|
assert all("text" in r and "score" in r for r in results)
|
|
assert all(isinstance(r["score"], float) for r in results), "score must be native float"
|
|
|
|
|
|
def test_mcp_search_relevance(mcp_index):
|
|
"""Search for 'payment gateway' should return relevant content."""
|
|
leann_bin = shutil.which("leann")
|
|
assert leann_bin is not None, "leann not found on PATH"
|
|
|
|
cmd = [
|
|
leann_bin,
|
|
"search",
|
|
"openclaw-memory",
|
|
"payment gateway timeout hotfix",
|
|
"--top-k=3",
|
|
"--complexity=32",
|
|
"--non-interactive",
|
|
"--json",
|
|
]
|
|
cwd = _project_root(mcp_index)
|
|
result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
|
|
assert result.returncode == 0, f"leann search failed (cwd={cwd}): {result.stderr[:500]}"
|
|
assert result.stdout.strip(), f"No output from leann search (stderr: {result.stderr[:500]})"
|
|
|
|
results = json.loads(result.stdout)
|
|
top_text = results[0]["text"].lower()
|
|
assert any(kw in top_text for kw in ["payment", "gateway", "hotfix", "timeout"])
|
|
|
|
|
|
def test_mcp_list(mcp_index):
|
|
"""leann list should show the openclaw-memory index."""
|
|
leann_bin = shutil.which("leann")
|
|
assert leann_bin is not None, "leann not found on PATH"
|
|
|
|
result = subprocess.run(
|
|
[leann_bin, "list"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=_project_root(mcp_index),
|
|
)
|
|
assert result.returncode == 0, f"leann list failed: {result.stderr[:300]}"
|
|
assert "openclaw-memory" in result.stdout
|
|
|
|
|
|
def test_mcp_stdio_protocol(mcp_index):
|
|
"""Spawn the MCP server as a subprocess, send JSON-RPC via stdin, read responses."""
|
|
leann_mcp_bin = shutil.which("leann_mcp")
|
|
if not leann_mcp_bin:
|
|
pytest.skip("leann_mcp not on PATH")
|
|
assert leann_mcp_bin is not None
|
|
|
|
requests = [
|
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
|
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
|
]
|
|
stdin_data = "\n".join(json.dumps(r) for r in requests) + "\n"
|
|
|
|
proc = subprocess.run(
|
|
[leann_mcp_bin],
|
|
input=stdin_data,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
|
|
lines = [line for line in proc.stdout.strip().split("\n") if line.strip()]
|
|
assert len(lines) >= 2, f"Expected 2 responses, got {len(lines)}: {proc.stdout[:300]}"
|
|
|
|
init_resp = json.loads(lines[0])
|
|
assert init_resp["id"] == 1
|
|
assert init_resp["result"]["serverInfo"]["name"] == "leann-mcp"
|
|
|
|
list_resp = json.loads(lines[1])
|
|
assert list_resp["id"] == 2
|
|
tool_names = {t["name"] for t in list_resp["result"]["tools"]}
|
|
assert "leann_search" in tool_names
|
|
assert "leann_list" in tool_names
|