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.
This commit is contained in:
Lore Engine Dev
2026-06-18 14:29:02 -04:00
parent 451325503e
commit ca5b1976ee
4 changed files with 306 additions and 0 deletions

23
.dockerignore Normal file
View File

@@ -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

39
Dockerfile Normal file
View File

@@ -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"]

27
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -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)