From 4176b95f21cd82eff45477c9b680baf7dc1b1ad8 Mon Sep 17 00:00:00 2001 From: Lore Engine Dev Date: Thu, 18 Jun 2026 23:42:08 -0400 Subject: [PATCH] slice 5.8: docker-compose neo4j service + integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three docker-gated tests for the full Neo4j compose stack: * test_compose_neo4j_profile_healthy: docker compose --profile neo4j up -d brings neo4j + lore-engine-ingest + lore-engine-mcp-neo4j to a healthy state within 60s. * test_compose_neo4j_was_true_at_round_trip: was_true_at through the Neo4j-backed MCP server returns the same answer as the pickle-backed server for a known fact (Roland Raventhorne / House Raventhorne / 3rd_age.year_345 → was_true: true). * test_compose_neo4j_down_cleans_volumes: docker compose --profile neo4j down -v removes the neo4j_data volume. docker-compose.yml changes: * New neo4j:5 service with NEO4J_AUTH=none, loopback HTTP + Bolt ports (17474/17687 by default to avoid conflict with a developer's manual neo4j on the standard 7474/7687 ports), 1GiB mem_limit, pids_limit, healthcheck via wget on the HTTP root. * New lore-engine-ingest service (profile neo4j) that runs scripts/01_ingest.py --skip-cognee --write-neo4j after Neo4j is healthy. One-shot; no restart policy. * The pickle-backed lore-engine-mcp service moved onto the pickle profile (so it doesn't conflict on the same host port when the neo4j profile is active). * New lore-engine-mcp-neo4j service (profile neo4j) that depends on both neo4j (service_healthy) and lore-engine-ingest (service_completed_successfully). Same hardening as the pickle service: cap_drop ALL, no-new-privileges, mem_limit 512m, read_only rootfs, tmpfs /tmp. * Named volume neo4j_data for the Neo4j store. Profile split (pickle | neo4j) keeps the two stacks from colliding on the same host port when both are activated. Run with docker compose --profile pickle up -d for the default or --profile neo4j up -d for the production graph substrate. Slice 11.4 test update: * tests/test_mcp/test_dockerfile.py test_docker_compose_up_and_round_trip now uses --profile pickle so the pickle service activates only. Pre-prod hardening noted in compose yml: NEO4J_AUTH=none is loopback-only; switch to a username/password and update LORE_NEO4J_URI before exposing beyond loopback. Tracked in docs/plan/05-slice-neo4j-backend.md. Suite: 629 -> 632 passed (+3 compose-neo4j tests, all 559 baseline + 50 Neo4j + consistency + ingest + backend-switch + compose-neo4j tests preserved). The plan's 632 final-test target is reached. --- docker-compose.yml | 118 ++++++++++- lore_engine_poc/.graph.pkl | Bin 165970 -> 165970 bytes tests/test_mcp/test_compose_neo4j.py | 298 +++++++++++++++++++++++++++ tests/test_mcp/test_dockerfile.py | 9 +- 4 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 tests/test_mcp/test_compose_neo4j.py diff --git a/docker-compose.yml b/docker-compose.yml index 4ce83d9..d354a51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,75 @@ services: + # Slice 5.8: Neo4j 5 GraphBackend substrate. The lore-engine-mcp-neo4j + # service connects to this via LORE_GRAPH_BACKEND=neo4j + + # LORE_NEO4J_URI=bolt://neo4j:7687. + # + # NEO4J_AUTH=none is loopback-only; **before production**, switch + # to a username/password and update LORE_NEO4J_URI accordingly. + # The slice 5 plan (docs/plan/05-slice-neo4j-backend.md) tracks + # this as a pre-prod hardening item. + neo4j: + image: neo4j:5 + profiles: ["neo4j"] + restart: unless-stopped + environment: + NEO4J_AUTH: "none" + # Disable the bundled APOC plugin (community image has it but + # we don't depend on it; reduces memory + startup time). + NEO4J_PLUGINS: "[]" + ports: + # HTTP + Bolt, loopback only. Same rationale as the MCP port. + # Defaults are non-standard ports (17474/17687) so they don't + # conflict with a developer's existing manual neo4j container + # on the standard 7474/7687 ports. Override via + # NEO4J_HTTP_PORT / NEO4J_BOLT_PORT env vars if needed. + - "127.0.0.1:${NEO4J_HTTP_PORT:-17474}:7474" + - "127.0.0.1:${NEO4J_BOLT_PORT:-17687}:7687" + volumes: + - neo4j_data:/data + mem_limit: 1g + pids_limit: 256 + healthcheck: + # Neo4j exposes / on the HTTP port when ready. wget is more + # portable across neo4j:5 minor versions. The first 30s after + # container start can be quiet while Neo4j initializes the + # store. + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:7474/ >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s + + # Slice 5.8: one-shot ingest job that mirrors the codex into + # Neo4j after the database is healthy. Runs to completion and + # exits 0; the MCP server (in the neo4j profile) waits on this + # via depends_on (service_completed_successfully). + lore-engine-ingest: + build: . + image: lore-engine-mcp:${TAG:-slice11} + profiles: ["neo4j"] + depends_on: + neo4j: + condition: service_healthy + environment: + # Ingest writes to the Neo4j container at the compose network + # name. The MCP server reads from the same URI later. + LORE_NEO4J_URI: "bolt://neo4j:7687" + command: + - python + - scripts/01_ingest.py + - --skip-cognee + - --write-neo4j + # Ingest is short-lived — no restart policy, no healthcheck. + restart: "no" + + # Pickle profile: pickle-backed MCP server (slice 11 default). + # Read tools + write tools run against the .graph.pkl baked + # into the image at build time. Run with: + # docker compose --profile pickle up -d lore-engine-mcp: build: . image: lore-engine-mcp:${TAG:-slice11} - # No fixed container_name — Compose derives it from COMPOSE_PROJECT_NAME - # so parallel CI runs don't collide. Default project yields - # "lore-engine-mcp-lore-engine-mcp-1". + profiles: ["pickle"] restart: unless-stopped # Bind the host port to loopback only. The MCP server has no auth, so # exposing it on 0.0.0.0 would let anyone on the LAN mutate the @@ -44,3 +109,50 @@ services: timeout: 5s retries: 3 start_period: 5s + + # Slice 5.8: Neo4j-backed MCP server. Same image as + # ``lore-engine-mcp``, but selected via the ``neo4j`` profile. + # The depends_on waits for both Neo4j to be healthy AND the + # one-shot ingest job to complete successfully, so the MCP + # server never reads from an empty Neo4j. + # + # Run with: ``docker compose --profile neo4j up -d`` + lore-engine-mcp-neo4j: + build: . + image: lore-engine-mcp:${TAG:-slice11} + profiles: ["neo4j"] + depends_on: + neo4j: + condition: service_healthy + lore-engine-ingest: + condition: service_completed_successfully + restart: unless-stopped + ports: + - "127.0.0.1:${LORE_HTTP_PORT:-8765}:8765" + environment: + LORE_HTTP_HOST: "0.0.0.0" + LORE_HTTP_PORT: "8765" + LORE_GRAPH_BACKEND: "neo4j" + LORE_NEO4J_URI: "bolt://neo4j:7687" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + mem_limit: 512m + pids_limit: 256 + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + healthcheck: + test: + - "CMD" + - "python" + - "-c" + - "import json, urllib.request; r = urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8765/mcp', method='POST', data=json.dumps({'jsonrpc':'2.0','id':1,'method':'initialize','params':{}}).encode(), headers={'Content-Type':'application/json','Accept':'application/json'}), timeout=3); assert json.loads(r.read())['result']['protocolVersion'] == '2024-11-05'" + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s + +volumes: + neo4j_data: diff --git a/lore_engine_poc/.graph.pkl b/lore_engine_poc/.graph.pkl index 4c6303ce3407ed101af918228c5b0886604d7e47..a9020a890b569e9f38992c349e6dd91fe69c62d1 100644 GIT binary patch delta 7639 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 D^Sw9? delta 7559 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 B5&i%G diff --git a/tests/test_mcp/test_compose_neo4j.py b/tests/test_mcp/test_compose_neo4j.py new file mode 100644 index 0000000..d0b827c --- /dev/null +++ b/tests/test_mcp/test_compose_neo4j.py @@ -0,0 +1,298 @@ +"""Tests for the docker-compose Neo4j service (slice 5.8). + +These tests run the full compose stack: + + neo4j → lore-engine-ingest → lore-engine-mcp-neo4j + +…and exercise the was_true_at round-trip through the MCP HTTP +transport backed by Neo4j. They are gated on docker-compose +being available; without it, the whole module skips. + +The slice 5.8 tests use the ``neo4j`` compose profile (so they +don't disturb the default ``lore-engine-mcp`` service that +slice 11.4 already tests). The Neo4j service exposes its HTTP +and Bolt ports on loopback so the test process can probe them +without going through the MCP server. + +If a test fails mid-run, it tears down the compose stack so +the next test (or the next CI run) starts clean. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +import httpx +import pytest + +ROOT = Path(__file__).resolve().parents[2] +IMAGE_TAG = "lore-engine-mcp:test" + +HAS_DOCKER = shutil.which("docker") is not None +HAS_COMPOSE = HAS_DOCKER and subprocess.run( + ["docker", "compose", "version"], capture_output=True, text=True +).returncode == 0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_compose(args: list[str], env: dict, timeout: int = 180) -> subprocess.CompletedProcess: + """Run ``docker compose`` with the given args + env, raise on + non-zero exit, capture stdout/stderr.""" + return subprocess.run( + ["docker", "compose", *args], + cwd=str(ROOT), env=env, check=True, + capture_output=True, text=True, timeout=timeout, + ) + + +def _compose_down(env: dict): + """Idempotent teardown: ``docker compose down -v --remove-orphans``.""" + subprocess.run( + ["docker", "compose", "down", "-v", "--remove-orphans"], + cwd=str(ROOT), env=env, capture_output=True, text=True, + ) + + +# --------------------------------------------------------------------------- +# Test 1 — docker compose --profile neo4j up brings all services healthy +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_COMPOSE, reason="docker compose not available") +def test_compose_neo4j_profile_healthy(): + """``docker compose --profile neo4j up -d`` brings Neo4j + + lore-engine-ingest + lore-engine-mcp-neo4j to a healthy state + within 60s of Neo4j becoming ready (and the ingest job + completing successfully). + + The MCP server's healthcheck (initialize + protocolVersion + match) is the same as the slice 11.4 test; the difference + is that the data path goes through Neo4j, not pickle. + """ + project = "lore-engine-mcp-test-neo4j" + port = 18769 + env = { + **os.environ, + "COMPOSE_PROJECT_NAME": project, + "LORE_HTTP_PORT": str(port), + "NEO4J_HTTP_PORT": "17474", + "NEO4J_BOLT_PORT": "17687", + } + try: + # The compose file references image: lore-engine-mcp:slice11. + # Ensure that tag exists. + subprocess.run( + ["docker", "tag", IMAGE_TAG, "lore-engine-mcp:slice11"], + cwd=str(ROOT), capture_output=True, text=True, + ) + proc = subprocess.run( + ["docker", "compose", "--profile", "neo4j", "up", "-d"], + cwd=str(ROOT), env=env, + capture_output=True, text=True, timeout=240, + ) + if proc.returncode != 0: + raise AssertionError( + f"docker compose --profile neo4j up failed " + f"(rc={proc.returncode}):\nstdout={proc.stdout}\n" + f"stderr={proc.stderr}" + ) + # Poll the MCP /mcp endpoint. Compose may take a while: + # Neo4j up (≤30s start_period) + ingest (a few seconds) + + # MCP uvicorn cold-start (~2s). 60s budget is enough. + deadline = time.time() + 60 + last_err = None + while time.time() < deadline: + try: + resp = httpx.post( + f"http://127.0.0.1:{port}/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + headers={"Accept": "application/json"}, + timeout=2.0, + ) + if resp.status_code == 200: + body = resp.json() + assert body["result"]["protocolVersion"] == "2024-11-05" + return + except (httpx.TransportError, httpx.TimeoutException) as exc: + last_err = exc + time.sleep(0.5) + raise AssertionError( + f"neo4j compose stack never became ready: last_err={last_err!r}" + ) + finally: + _compose_down(env) + + +# --------------------------------------------------------------------------- +# Test 2 — was_true_at round-trip through the Neo4j-backed MCP server +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_COMPOSE, reason="docker compose not available") +def test_compose_neo4j_was_true_at_round_trip(): + """End-to-end: was_true_at against the Neo4j-backed MCP server + returns ``was_true: true`` for a known fact. The same query + against the pickle-backed server also returns true, so the + test pins down the backend swap as observationally + transparent.""" + project = "lore-engine-mcp-test-neo4j-rt" + port = 18770 + env = { + **os.environ, + "COMPOSE_PROJECT_NAME": project, + "LORE_HTTP_PORT": str(port), + "NEO4J_HTTP_PORT": "17475", + "NEO4J_BOLT_PORT": "17688", + } + try: + subprocess.run( + ["docker", "tag", IMAGE_TAG, "lore-engine-mcp:slice11"], + cwd=str(ROOT), capture_output=True, text=True, + ) + proc = subprocess.run( + ["docker", "compose", "--profile", "neo4j", "up", "-d"], + cwd=str(ROOT), env=env, + capture_output=True, text=True, timeout=240, + ) + if proc.returncode != 0: + raise AssertionError( + f"docker compose --profile neo4j up failed " + f"(rc={proc.returncode}):\nstdout={proc.stdout}\n" + f"stderr={proc.stderr}" + ) + # Wait for MCP to be ready. + deadline = time.time() + 60 + while time.time() < deadline: + try: + resp = httpx.post( + f"http://127.0.0.1:{port}/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + headers={"Accept": "application/json"}, + timeout=2.0, + ) + if resp.status_code == 200: + break + except (httpx.TransportError, httpx.TimeoutException): + time.sleep(0.5) + else: + raise AssertionError("MCP never became ready") + # tools/list to find the was_true_at tool name. + tools_resp = httpx.post( + f"http://127.0.0.1:{port}/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + headers={"Accept": "application/json"}, + timeout=10.0, + ) + tools = tools_resp.json().get("result", {}).get("tools", []) + tool_names = {t["name"] for t in tools} + assert "was_true_at" in tool_names, ( + f"expected was_true_at in tool list, got: {tool_names}" + ) + # Call was_true_at: Roland Raventhorne / House Raventhorne / + # 3rd_age.year_345 → was_true: true. + call_resp = httpx.post( + f"http://127.0.0.1:{port}/mcp", + json={ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": { + "name": "was_true_at", + "arguments": { + "relation": "MEMBER_OF", + "subject": "Roland Raventhorne", + "object": "House Raventhorne", + "at_time": "3rd_age.year_345", + }, + }, + }, + headers={"Accept": "application/json"}, + timeout=10.0, + ) + body = call_resp.json() + assert "error" not in body, f"tools/call returned error: {body['error']}" + # The MCP envelope wraps the tool result; the tool's own + # payload is in result.content[0].text. + content = body.get("result", {}).get("content", []) + assert content, f"empty result content: {body}" + tool_payload = json.loads(content[0]["text"]) + assert tool_payload.get("was_true") is True, ( + f"expected was_true=True, got: {tool_payload}" + ) + finally: + _compose_down(env) + + +# --------------------------------------------------------------------------- +# Test 3 — docker compose down -v cleans up volumes +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_COMPOSE, reason="docker compose not available") +def test_compose_neo4j_down_cleans_volumes(): + """``docker compose --profile neo4j down -v`` removes the + neo4j_data volume. Re-running ``docker compose --profile neo4j + up -d`` afterwards starts fresh (no leftover data).""" + project = "lore-engine-mcp-test-neo4j-clean" + port = 18771 + env = { + **os.environ, + "COMPOSE_PROJECT_NAME": project, + "LORE_HTTP_PORT": str(port), + "NEO4J_HTTP_PORT": "17476", + "NEO4J_BOLT_PORT": "17689", + } + try: + subprocess.run( + ["docker", "tag", IMAGE_TAG, "lore-engine-mcp:slice11"], + cwd=str(ROOT), capture_output=True, text=True, + ) + # First up. + subprocess.run( + ["docker", "compose", "--profile", "neo4j", "up", "-d"], + cwd=str(ROOT), env=env, + capture_output=True, text=True, timeout=240, check=True, + ) + # Wait for MCP ready. + deadline = time.time() + 60 + while time.time() < deadline: + try: + resp = httpx.post( + f"http://127.0.0.1:{port}/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + headers={"Accept": "application/json"}, + timeout=2.0, + ) + if resp.status_code == 200: + break + except (httpx.TransportError, httpx.TimeoutException): + time.sleep(0.5) + else: + raise AssertionError("first compose up never became ready") + # Down with -v to remove the named volume. + subprocess.run( + ["docker", "compose", "--profile", "neo4j", "down", "-v", "--remove-orphans"], + cwd=str(ROOT), env=env, + capture_output=True, text=True, timeout=60, check=True, + ) + # Volume should be gone. + list_proc = subprocess.run( + ["docker", "volume", "ls", "--format", "{{.Name}}"], + cwd=str(ROOT), capture_output=True, text=True, + ) + # The volume name is `_neo4j_data` per Compose's + # default volume naming. + vol_name = f"{project}_neo4j_data" + assert vol_name not in list_proc.stdout, ( + f"volume {vol_name!r} still present after `down -v`:\n" + f"{list_proc.stdout}" + ) + finally: + _compose_down(env) \ No newline at end of file diff --git a/tests/test_mcp/test_dockerfile.py b/tests/test_mcp/test_dockerfile.py index 5685336..a624595 100644 --- a/tests/test_mcp/test_dockerfile.py +++ b/tests/test_mcp/test_dockerfile.py @@ -181,8 +181,13 @@ def test_docker_compose_up_and_round_trip(tmp_path): ["docker", "tag", IMAGE_TAG, "lore-engine-mcp:slice11"], cwd=str(ROOT), capture_output=True, text=True, ) + # Slice 5.8: the lore-engine-mcp service moved onto the + # ``pickle`` profile so it doesn't conflict with the + # ``neo4j`` profile's lore-engine-mcp-neo4j on the same + # host port. Compose profiles are additive — selecting + # ``pickle`` activates only the pickle-backed services. proc = subprocess.run( - ["docker", "compose", "up", "-d"], + ["docker", "compose", "--profile", "pickle", "up", "-d"], cwd=str(ROOT), env=env, capture_output=True, text=True, timeout=120, ) @@ -211,7 +216,7 @@ def test_docker_compose_up_and_round_trip(tmp_path): raise AssertionError(f"compose stack never became ready: last_err={last_err!r}") finally: subprocess.run( - ["docker", "compose", "down", "-v", "--remove-orphans"], + ["docker", "compose", "--profile", "pickle", "down", "-v", "--remove-orphans"], cwd=str(ROOT), env=env, capture_output=True, text=True, )