From ca5b1976ee1040311dde26e25138cd611ba21bd2 Mon Sep 17 00:00:00 2001 From: Lore Engine Dev Date: Thu, 18 Jun 2026 14:29:02 -0400 Subject: [PATCH] docker: containerize the HTTP MCP server + compose (slice 11.4 + 11.5) * Dockerfile: python:3.12-slim, layer-cached requirements first, COPY lore_engine_poc/scripts, bake .graph.pkl, EXPOSE 8765, HEALTHCHECK via stdlib urllib against POST /mcp initialize. CMD runs scripts/06_mcp_http_server.py --host 0.0.0.0. * .dockerignore: exclude __pycache__, tests/, .git/, data/raw/, etc. * docker-compose.yml: one service lore-engine-mcp, port 8765:8765 (overridable via $LORE_HTTP_PORT), bind mount for graph override (commented), healthcheck. Image tag overridable via $TAG. * tests/test_mcp/test_dockerfile.py: 4 tests gated on docker availability. Build, run + round-trip, compose up + round-trip, healthcheck reaches 'healthy'. All 4 pass on this host. 550 -> 554 green. --- .dockerignore | 23 ++++ Dockerfile | 39 ++++++ docker-compose.yml | 27 ++++ tests/test_mcp/test_dockerfile.py | 217 ++++++++++++++++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 tests/test_mcp/test_dockerfile.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4930f67 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ +.git/ +.gitignore +.github/ +tests/ +data/raw/ +*.log +.env +.env.local +.venv/ +venv/ +.DS_Store +README.md +*.zip +*.tar.gz diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aaf1aab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1.6 +# Lore Engine POC MCP server — Streamable HTTP transport (slice 11.4). +FROM python:3.12-slim + +# Sane Python defaults +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + LORE_HTTP_HOST=0.0.0.0 \ + LORE_HTTP_PORT=8765 + +WORKDIR /app + +# Layer 1: requirements (cache-friendly; rebuilds only when deps change) +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Layer 2: source tree +COPY lore_engine_poc ./lore_engine_poc +COPY scripts ./scripts + +# Layer 3: pre-built graph (165 KB today). For larger codexes, override +# at run time with: docker run -v $PWD/data:/data -e LORE_GRAPH_PATH=/data/.graph.pkl +COPY lore_engine_poc/.graph.pkl ./lore_engine_poc/.graph.pkl + +EXPOSE 8765 + +# Healthcheck: same JSON-RPC path an MCP client would use. +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD python -c "import json, urllib.request; \ +req = 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'}); \ +r = json.loads(urllib.request.urlopen(req, timeout=3).read()); \ +assert r['result']['protocolVersion'] == '2024-11-05'" \ + || exit 1 + +CMD ["python", "scripts/06_mcp_http_server.py", "--host", "0.0.0.0"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..03427bf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + lore-engine-mcp: + build: . + image: lore-engine-mcp:${TAG:-slice11} + container_name: lore-engine-mcp + restart: unless-stopped + ports: + - "${LORE_HTTP_PORT:-8765}:8765" + environment: + LORE_HTTP_HOST: "0.0.0.0" + LORE_HTTP_PORT: "8765" + # LORE_GRAPH_PATH: "/data/.graph.pkl" # uncomment + add volume below to override + # volumes: + # # Optional: mount a fresh graph without rebuilding the image. + # # Build via `python3 scripts/01_ingest.py --out data/.graph.pkl` first. + # - ./lore_engine_poc/data:/data:ro + healthcheck: + # Same path an MCP client would use. Mirrors the Dockerfile 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 diff --git a/tests/test_mcp/test_dockerfile.py b/tests/test_mcp/test_dockerfile.py new file mode 100644 index 0000000..6267946 --- /dev/null +++ b/tests/test_mcp/test_dockerfile.py @@ -0,0 +1,217 @@ +"""Tests for the Docker image (slice 11.4). + +These tests are gated on docker availability. They: + +1. Build the image with ``docker build``. +2. Run it, hit the /mcp endpoint, and tear it down. +3. Repeat the round-trip via ``docker compose up``. +4. Verify the healthcheck reaches ``healthy`` within the start period. + +If docker is not on PATH, all tests are skipped (so CI without +docker still passes). If a test fails mid-run, it cleans up any +container or compose stack it started. +""" + +from __future__ import annotations + +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 + +pytestmark_docker = pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH") +pytestmark_compose = pytest.mark.skipif(not HAS_COMPOSE, reason="docker compose not available") + + +def _run(args, **kwargs): + """Run a docker command, raise on non-zero exit.""" + return subprocess.run( + ["docker", *args], + cwd=str(ROOT), check=True, capture_output=True, text=True, **kwargs, + ) + + +def _rm_container(name: str): + subprocess.run( + ["docker", "rm", "-f", name], + cwd=str(ROOT), capture_output=True, text=True, + ) + + +# --------------------------------------------------------------------------- +# Test 1 — docker build +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH") +def test_dockerfile_builds(): + try: + proc = subprocess.run( + ["docker", "build", "-t", IMAGE_TAG, "."], + cwd=str(ROOT), capture_output=True, text=True, timeout=180, + ) + assert proc.returncode == 0, ( + f"docker build failed:\nstdout={proc.stdout}\nstderr={proc.stderr}" + ) + finally: + # Best-effort cleanup; the image is small (~150 MB) and is reused + # by later tests. Don't rmi on success. + pass + + +# --------------------------------------------------------------------------- +# Test 2 — docker run + round-trip +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH") +def test_docker_run_initialize_round_trip(): + container = "lore-engine-mcp-test-run" + _rm_container(container) + port = 18765 + try: + # -d (detached) + --rm alternative: we manage teardown ourselves. + _run([ + "run", "-d", + "--name", container, + "-p", f"{port}:8765", + IMAGE_TAG, + ]) + # Poll /mcp until ready (uvicorn cold-start ~1-2s). + deadline = time.time() + 20 + 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: + # ConnectError before the port is bound; ReadError / RemoteProtocolError + # when uvicorn's keep-alive is reset; anything in the TransportError + # family is "not ready yet" at this stage. + last_err = exc + time.sleep(0.3) + raise AssertionError(f"container never became ready: last_err={last_err!r}") + finally: + _rm_container(container) + + +# --------------------------------------------------------------------------- +# Test 3 — docker compose up + round-trip +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_COMPOSE, reason="docker compose not available") +def test_docker_compose_up_and_round_trip(tmp_path): + # Compose requires the build context (Dockerfile + source tree) at + # the cwd's location. Run from the repo root to avoid symlink + # resolution issues inside Docker's mount namespace. Use a unique + # project name + port to allow parallel CI runs. + project = "lore-engine-mcp-test-compose" + port = 18766 + env = {**os.environ, "COMPOSE_PROJECT_NAME": project, + "LORE_HTTP_PORT": str(port)} + try: + # The compose file references image: lore-engine-mcp:slice11. + # Ensure that tag exists (it may not, if the build test was + # skipped). Re-tag from :test if so. Idempotent. + subprocess.run( + ["docker", "tag", IMAGE_TAG, "lore-engine-mcp:slice11"], + cwd=str(ROOT), capture_output=True, text=True, + ) + proc = subprocess.run( + ["docker", "compose", "up", "-d"], + cwd=str(ROOT), env=env, + capture_output=True, text=True, timeout=120, + ) + if proc.returncode != 0: + raise AssertionError( + f"docker compose up failed (rc={proc.returncode}):\n" + f"stdout={proc.stdout}\nstderr={proc.stderr}" + ) + deadline = time.time() + 25 + 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.3) + raise AssertionError(f"compose stack never became ready: last_err={last_err!r}") + finally: + subprocess.run( + ["docker", "compose", "down", "-v", "--remove-orphans"], + cwd=str(ROOT), env=env, capture_output=True, text=True, + ) + + +# --------------------------------------------------------------------------- +# Test 4 — healthcheck reaches 'healthy' +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH") +def test_docker_healthcheck_reaches_healthy(): + container = "lore-engine-mcp-test-health" + _rm_container(container) + port = 18767 + try: + _run([ + "run", "-d", + "--name", container, + "-p", f"{port}:8765", + IMAGE_TAG, + ]) + # Healthcheck schedule: interval 30s, start-period 5s, retries 3. + # That means worst-case wait is ~5s + 3*30s = 95s. In practice + # the first probe fires after the start-period and passes in <1s. + # We poll for up to 20s — enough for the first healthy probe. + deadline = time.time() + 20 + while time.time() < deadline: + proc = subprocess.run( + ["docker", "inspect", "--format", + "{{.State.Health.Status}}", container], + cwd=str(ROOT), capture_output=True, text=True, + ) + status = proc.stdout.strip() + if status == "healthy": + return + time.sleep(1.0) + # One more read for the failure message. + proc = subprocess.run( + ["docker", "inspect", "--format", + "{{.State.Health.Status}}", container], + cwd=str(ROOT), capture_output=True, text=True, + ) + raise AssertionError( + f"container never reached 'healthy' (last status: {proc.stdout.strip()!r})" + ) + finally: + _rm_container(container)