Skip to content

Running hwlib-mcp in a Docker container

Use the container when you want to:

  • Host the MCP server for a team (Streamable HTTP transport, one server, many agent clients across a network).
  • Pin to an exact bundle snapshot in a CI job that consumes the catalog.
  • Distribute the catalog into an air-gap environment as a single image artifact (Prompt 10 wires the GHCR push).

For local solo use, prefer uvx hwlib-mcp over a container — Docker adds latency and an extra layer with no upside when stdio works.

Quickstart

# 1. Build the bundle and the image (one shot).
bash packages/hwlib-mcp/scripts/build-image.sh

# 2. Bring up the service in --http mode with the bundle mounted.
make compose-up

# 3. Probe.
curl -s http://localhost:8080/health | jq
# {"status":"ok","bundle_present":true,"component_count":8,"schema_version":1}

# 4. Wire an agent.
claude mcp add hwlib --transport http http://localhost:8080/mcp

# 5. Tear down.
make compose-down

Pulling from GHCR

docker pull ghcr.io/rnd-southerniot/hwlib-mcp:latest
docker run -d --name hwlib \
    -p 8080:8080 \
    ghcr.io/rnd-southerniot/hwlib-mcp:latest \
    --http --port 8080

HWLIB_DATA_DIR is unset — the image bakes in a copy of the bundle at /opt/hwlib/data matching the image tag. Override only when you want a different bundle:

The image bakes in a frozen copy of the bundle that matches the image tag (a v0.1.0 image carries the v0.1.0 bundle). Override at runtime by mounting your own:

docker run -d \
    -v /path/to/your/dist:/opt/hwlib/data:ro \
    -p 8080:8080 \
    ghcr.io/rnd-southerniot/hwlib-mcp:latest \
    --http --port 8080

Image structure

Multi-stage build:

Stage Base image Purpose
builder python:3.13-slim + uv Install package + deps into /opt/site-packages; copy dist/ into /opt/hwlib/data/
runtime gcr.io/distroless/python3-debian13:nonroot Copies the two prepared dirs; runs as nonroot (uid 65532); no shell, no apt

The runtime stage is distroless by design. There is no shell, no package manager, no bash, no sh, no apt. docker exec -it hwlib-mcp sh will fail. This is correct for a single-purpose runtime — the smaller attack surface is worth more than ergonomic shell access.

Debugging

You cannot docker exec into the container. The two debugging surfaces:

# 1. Container stdout/stderr — hwlib-mcp logs go to stderr.
docker logs hwlib-mcp -f

# 2. The MCP protocol itself, via FastMCP's introspection or the
#    /health endpoint in --http mode.
curl -s http://localhost:8080/health | jq

To enable verbose server logs (per-tool-call timings and decisions), override the command:

docker run --rm \
    -e HWLIB_DATA_DIR=/opt/hwlib/data \
    -v $(pwd)/dist:/opt/hwlib/data:ro \
    -p 8080:8080 \
    hwlib-mcp:dev \
    --http --port 8080 --log-level debug

Bundle-missing behaviour

Starting the container without a bundle exits non-zero quickly with a structured error message on stderr, not a hang on stdio:

docker run --rm hwlib-mcp:dev
# Container exits with code 2; logs:
#   hwlib-mcp: library.sqlite not found at /opt/hwlib/data.
#     Run `python -m tools.builder --out dist/` and/or set
#     HWLIB_DATA_DIR to a directory that contains library.sqlite.

The image bakes in a bundle at build time, so this only fires when the mount overrides it with an empty volume — which is itself a useful sanity check during deployment.

Health endpoint

GET /health (HTTP-only, outside the MCP protocol surface) returns:

HTTP Body shape
200 {"status": "ok", "bundle_present": true, "component_count": <int>, "schema_version": <int>}
503 {"status": "bundle_missing", "bundle_present": false, "data_dir": "<path>"}

Compose's healthcheck uses this via urllib.request.urlopen (no shell required). Orchestrators (Kubernetes, Nomad, ECS) should configure a liveness probe against the same endpoint.