diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..207f355 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.venv +env +data +internal +demo +tests +.pytest_cache +__pycache__ +**/__pycache__ +*.pyc +*.pyo +*.pyd +*.log +.DS_Store diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..4ced0dc --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,59 @@ +name: Docker Publish + +on: + push: + branches: [main] + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/razpines/unityrag + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=tag + + - name: Build and push image + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Build image only (manual/non-publish refs) + if: github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/v') + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff5d4f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + UNITY_DOCS_MCP_ROOT=/app \ + UNITY_DOCS_MCP_CONFIG=/app/config.docker.yaml \ + UNITY_DOCS_MCP_HOST=0.0.0.0 \ + UNITY_DOCS_MCP_PORT=8765 + +WORKDIR /app + +COPY pyproject.toml README.md LICENSE ./ +COPY config.yaml config.docker.yaml ./ +COPY src ./src + +RUN python -m pip install --upgrade pip && \ + python -m pip install . + +EXPOSE 8765 + +VOLUME ["/app/data"] + +CMD ["unitydocs-mcp-http"] diff --git a/README.md b/README.md index 4f8e22b..029ee07 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ ![UnityRAG demo comparison](demo/assets/ad_demo_readme_dark.png) [![CI](https://github.com/Razpines/UnityRAG/actions/workflows/ci.yml/badge.svg)](https://github.com/Razpines/UnityRAG/actions/workflows/ci.yml) +[![Docker GHCR](https://img.shields.io/badge/Docker-GHCR-2496ED?logo=docker&logoColor=white)](https://github.com/Razpines/UnityRAG/pkgs/container/unityrag) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](pyproject.toml) [![GitHub Stars](https://img.shields.io/github/stars/Razpines/UnityRAG)](https://github.com/Razpines/UnityRAG/stargazers) @@ -45,6 +46,50 @@ unitydocs doctor unitydocs doctor --json ``` +## Docker Quick Start (CPU-only) + +Use the official CPU-first container image to run the MCP HTTP server without local Python/venv setup. + +Run: + +```bash +docker run -d --name unityrag \ + -p 8765:8765 \ + -v unityrag-data:/app/data \ + ghcr.io/razpines/unityrag:latest +``` + +Wait / first startup: +- First startup is slow: the container downloads Unity docs, bakes artifacts, and builds the FTS index. +- Watch progress with `docker logs -f unityrag`. +- Data persists across container recreation via the mounted volume (`/app/data`). + +Verify: +- MCP HTTP endpoint is `http://localhost:8765/mcp`. +- A plain `GET /mcp` may return `406 Not Acceptable`; this is expected for streamable HTTP when the client does not send `Accept: text/event-stream`. + +Connect (Codex CLI): + +```bash +codex mcp add unity-docs-http --url http://localhost:8765/mcp +``` + +Common adjustments: +- Port `8765` already in use: map a different host port, for example `-p 8876:8765`, then connect to `http://localhost:8876/mcp`. +- Remote-hosted server: use `http://:8765/mcp` (or your remapped host port). +- The Docker container must stay running while your MCP client is connected. + +Container defaults (advanced): +- `UNITY_DOCS_MCP_ROOT=/app` +- `UNITY_DOCS_MCP_CONFIG=/app/config.docker.yaml` (FTS-only / CPU-safe) +- `UNITY_DOCS_MCP_HOST=0.0.0.0` +- `UNITY_DOCS_MCP_PORT=8765` + +Client config details (Docker-hosted MCP): +- `docs/docker_http_clients.md` + +Initial Docker scope is CPU-only / FTS-only. GPU images and Docker Compose quick start are tracked separately. + ## Why UnityRAG - Uses official Unity offline docs for versioned retrieval. @@ -95,6 +140,7 @@ pytest ## Docs - Developer details: `docs/README.md` +- Docker-hosted MCP clients: `docs/docker_http_clients.md` ## Content and Licensing diff --git a/config.docker.yaml b/config.docker.yaml new file mode 100644 index 0000000..5d50746 --- /dev/null +++ b/config.docker.yaml @@ -0,0 +1,3 @@ +# CPU-only container overrides layered on top of config.yaml. +index: + vector: "none" diff --git a/docs/docker_http_clients.md b/docs/docker_http_clients.md new file mode 100644 index 0000000..986018c --- /dev/null +++ b/docs/docker_http_clients.md @@ -0,0 +1,74 @@ +# Docker-Hosted MCP Clients (HTTP) + +This page shows how to connect MCP clients to a UnityRAG server running in Docker. + +The Docker image runs a streamable HTTP MCP server at: +- `http://localhost:8765/mcp` (same machine) +- `http://:8765/mcp` (remote host) + +## Before You Connect + +- Start the container (see `README.md` Docker quick start). +- First startup can take a while (download + bake + index). +- Watch progress with `docker logs -f unityrag`. +- A plain `GET /mcp` may return `406 Not Acceptable`; that is expected for streamable HTTP when the request does not accept `text/event-stream`. + +## Codex (Verified) + +Add the Docker-hosted MCP server: + +```bash +codex mcp add unity-docs-http --url http://localhost:8765/mcp +``` + +Remote host example: + +```bash +codex mcp add unity-docs-http --url http://:8765/mcp +``` + +Confirm: + +```bash +codex mcp list +``` + +## Claude Desktop (Planned: verify exact HTTP config schema) + +We will document an exact Claude Desktop HTTP MCP config snippet after local verification. + +Expected shape (conceptually): +- Use an HTTP/streamable MCP server entry (not `command`/`args`) +- Point it to `http://localhost:8765/mcp` (or remote host URL) +- No UnityRAG process path is needed because Docker is running the server + +## Cline / GitHub Copilot / Other MCP Clients (Compatibility Planning) + +Use the same endpoint concept: +- `http://localhost:8765/mcp` (local) +- `http://:8765/mcp` (remote) + +Client-specific config format varies by product and version. We only publish exact snippets after end-to-end verification. + +## Compatibility Status (Docker-hosted HTTP MCP) + +| Client | How to Configure | Status | Notes | +| --- | --- | --- | --- | +| Codex | CLI (`codex mcp add ... --url ...`) | Verified | Uses streamable HTTP MCP URL directly | +| Claude Desktop | Desktop config (HTTP MCP entry) | Planned | Exact schema/version to verify before publishing snippet | +| Cline | Client config (HTTP MCP entry) | Planned | Format varies; verify before publishing | +| GitHub Copilot | Product-specific MCP integration | Planned | Verify product support/path before publishing | + +## Troubleshooting + +- Port collision: + - Start Docker with a different host port, e.g. `-p 8876:8765` + - Connect clients to `http://localhost:8876/mcp` +- Container not running: + - `docker ps` + - `docker logs -f unityrag` +- Remote host unreachable: + - Check firewall / LAN routing / port exposure + - Confirm the server is listening on `0.0.0.0` (Docker image default) +- Client gets an HTTP error on manual browser/curl check: + - `406` on plain `GET` is usually expected for this transport diff --git a/src/unity_docs_mcp/config.py b/src/unity_docs_mcp/config.py index efc2434..8d1820a 100644 --- a/src/unity_docs_mcp/config.py +++ b/src/unity_docs_mcp/config.py @@ -128,6 +128,9 @@ def deep_update(target: Dict[str, Any], incoming: Dict[str, Any]) -> Dict[str, A def _repo_root() -> Path: + root_override = os.environ.get("UNITY_DOCS_MCP_ROOT") + if root_override: + return Path(root_override).expanduser() return Path(__file__).resolve().parents[2] diff --git a/tests/test_config_loading.py b/tests/test_config_loading.py index 4b5ab3e..ea108b1 100644 --- a/tests/test_config_loading.py +++ b/tests/test_config_loading.py @@ -60,3 +60,22 @@ def test_load_config_explicit_path_has_highest_precedence(monkeypatch, tmp_path: cfg = config_mod.load_config(explicit_cfg) assert cfg.unity_version == "6000.0" + + +def test_load_config_uses_unity_docs_mcp_root_for_base_layers(monkeypatch, tmp_path: Path): + _write( + tmp_path / "config.yaml", + """ +paths: + root: "data/unity/from-root-override" +index: + vector: "none" +""".strip() + + "\n", + ) + monkeypatch.setenv("UNITY_DOCS_MCP_ROOT", str(tmp_path)) + monkeypatch.delenv("UNITY_DOCS_MCP_CONFIG", raising=False) + + cfg = config_mod.load_config() + assert cfg.paths.root == "data/unity/from-root-override" + assert cfg.index.vector == "none"