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:
23
.dockerignore
Normal file
23
.dockerignore
Normal 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
39
Dockerfile
Normal 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
27
docker-compose.yml
Normal 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
|
||||
217
tests/test_mcp/test_dockerfile.py
Normal file
217
tests/test_mcp/test_dockerfile.py
Normal 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)
|
||||
Reference in New Issue
Block a user