From 9255f72ddc5df9f7db41113cbf762e4ea927043d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 17 Aug 2026 02:34:21 +0000 Subject: [PATCH] unifi-network-mcp image: Dockerfile + Gitea Actions build workflow + README --- .gitea/workflows/build.yaml | 106 ++++++++++++++++++++++++++++++++++++ Dockerfile | 42 ++++++++++++++ README.md | 63 +++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 .gitea/workflows/build.yaml create mode 100644 Dockerfile create mode 100644 README.md diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..68b3c93 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,106 @@ +# Builds and publishes the unifi-network-mcp container image to the Gitea +# registry. +# +# Triggers: +# - push to main (e.g. bumping the Dockerfile's default version ARG) +# - weekly schedule, Mon 06:00 UTC — resolves the newest published +# unifi-network-mcp on PyPI and builds it if not yet published. This +# lands the new tag ~7h BEFORE the cluster0 image-update cron +# (Mon 13:00 UTC) scans registries, so the Monday pass picks it up like +# any other app. +# - workflow_dispatch with an explicit version input +name: build + +on: + push: + branches: [main] + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + inputs: + version: + description: 'unifi-network-mcp PyPI version (empty = latest published)' + required: false + default: '' + +env: + REGISTRY: gitea.williammiceli.systems + IMAGE: gitea.williammiceli.systems/hermes/unifi-network-mcp + +jobs: + build: + runs-on: ubuntu-latest # cluster0 Gitea runner (registers this label; has docker + network) + # Minimal explicit scopes: read code (checkout), write packages (registry push). + # Declared explicitly so the workflow keeps working even if the repo/owner + # default token permission mode is switched to Restricted. + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve upstream version + id: ver + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + VER="${{ github.event.inputs.version }}" + else + VER=$(curl -sf https://pypi.org/pypi/unifi-network-mcp/json \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])") + fi + [ -n "$VER" ] || { echo "could not resolve upstream version"; exit 1; } + echo "version=$VER" >> "$GITHUB_OUTPUT" + echo "Resolved unifi-network-mcp version: $VER" + + - name: Configure registry credentials + # Uses a dedicated scoped PAT (repo Actions secret REGISTRY_TOKEN, + # read/write package scope only) instead of the built-in job token: + # Gitea's container registry rejects scoped /v2/token requests made + # with the Actions job token (GetAccessScope returns empty scope for + # ActionTokenMethodName -> Authenticate 401s, upstream issue #23642, + # unfixed as of 1.27.2). Write config.json directly; no login ping. + run: | + mkdir -p "$HOME/.docker" + AUTH=$(printf 'Hermes:%s' "${{ secrets.REGISTRY_TOKEN }}" | base64 -w0) + printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY" "$AUTH" > "$HOME/.docker/config.json" + chmod 600 "$HOME/.docker/config.json" + echo "docker config.json written for $REGISTRY" + + - name: Diagnose registry auth (runs only if a later step fails) + if: failure() + # Prints only metadata (lengths, status codes) — never the token itself. + run: | + T="${{ secrets.REGISTRY_TOKEN }}" + echo "REGISTRY_TOKEN length: ${#T} (0 = secret empty or missing)" + echo "--- /v2/ ping with basic auth ---" + curl -s -o /dev/null -w "HTTP %{http_code}\n" -u "Hermes:$T" \ + "https://gitea.williammiceli.systems/v2/" || true + echo "--- bearer-token flow (what docker push does after a 401 challenge) ---" + BT=$(curl -s -u "Hermes:$T" -G "https://gitea.williammiceli.systems/v2/token" \ + --data-urlencode "service=container_registry" \ + --data-urlencode "scope=repository:hermes/unifi-network-mcp:pull,push" \ + | python3 -c "import sys,json;print(json.load(sys.stdin).get('token',''))") + echo "bearer token length: ${#BT}" + curl -s -o /dev/null -w "bearer tags/list: HTTP %{http_code}\n" \ + -H "Authorization: Bearer ***" \ + "https://gitea.williammiceli.systems/v2/hermes/unifi-network-mcp/tags/list" || true + + - name: Skip if already published + id: skip + run: | + if docker manifest inspect "$IMAGE:v${{ steps.ver.outputs.version }}" >/dev/null 2>&1; then + echo "Image $IMAGE:v${{ steps.ver.outputs.version }} already exists — nothing to do." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push + if: steps.skip.outputs.skip != 'true' + run: | + VER="${{ steps.ver.outputs.version }}" + docker build --build-arg UNIFI_NETWORK_MCP_VERSION="$VER" \ + -t "$IMAGE:v$VER" -t "$IMAGE:latest" . + docker push "$IMAGE:v$VER" + docker push "$IMAGE:latest" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cb4f853 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# unifi-network-mcp — the UniFi Network MCP server (unifi-network-mcp on +# PyPI, from sirkirby/unifi-mcp) as a streamable-HTTP container for cluster0. +# +# Built by Gitea Actions (.gitea/workflows/build.yaml): +# - on push to main +# - weekly Mon 06:00 UTC (resolves newest published upstream, skips if unchanged) +# - on manual workflow_dispatch with an explicit version +# +# The upstream unifi-network-mcp version is pinned via the +# UNIFI_NETWORK_MCP_VERSION build-arg. Published as +# gitea.williammiceli.systems/hermes/unifi-network-mcp:v (plus +# :latest). The cluster0 image-update cron then bumps the deployment tag +# like any other app. +# +# Controller credentials (UNIFI_HOST / UNIFI_USERNAME / UNIFI_PASSWORD) are +# injected at deploy time from the server's own Kubernetes secret — they are +# NEVER baked into this image. + +ARG UNIFI_NETWORK_MCP_VERSION=0.27.1 + +FROM docker.io/library/python:3.13-slim + +ARG UNIFI_NETWORK_MCP_VERSION +RUN pip install --no-cache-dir unifi-network-mcp==${UNIFI_NETWORK_MCP_VERSION} + +# This image exists to serve streamable-HTTP MCP in-cluster, so enable the +# HTTP transport here (upstream default is stdio). As PID 1 the server runs +# HTTP-only (stdio is skipped when no client is attached to stdin). +ENV UNIFI_MCP_HTTP_ENABLED=true +ENV UNIFI_MCP_HTTP_TRANSPORT=streamable-http +# Bind all interfaces by default; UNIFI_MCP_ALLOWED_HOSTS is set by the +# Deployment (the server's DNS-rebinding protection validates Host headers +# against this allowlist). +ENV UNIFI_MCP_HOST=0.0.0.0 +ENV UNIFI_MCP_PORT=3000 +EXPOSE 3000 + +# No HEALTHCHECK: upstream exposes no /health endpoint on the HTTP transport +# (only /mcp). The Kubernetes Deployment uses tcpSocket probes instead. + +# Console-script entrypoint installed by the PyPI package. +ENTRYPOINT ["unifi-network-mcp"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..79dfd9a --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# unifi-network-mcp + +The [UniFi Network MCP server](https://github.com/sirkirby/unifi-mcp) +(`unifi-network-mcp` on PyPI) packaged as a container image, built by Gitea +Actions in this repo. Runs the server in streamable-HTTP mode so MCP clients +(Hermes Agent) can call it over the network without carrying any controller +credentials — `UNIFI_HOST` / `UNIFI_USERNAME` / `UNIFI_PASSWORD` live only in +the deployed server's own Kubernetes secret. + +- **Image:** `gitea.williammiceli.systems/hermes/unifi-network-mcp` +- **Consumed by:** `William/Kubernetes_Cluster0` → `cluster0 (Personal)/#active/#applications/ai-0 (Hermes Agent)/ai-0-3 (UniFi MCP)/` + +## Why this server + +Two active open-source UniFi MCP servers were evaluated: + +| | `unifi-network-mcp` (sirkirby/unifi-mcp) — chosen | `unifi-mcp-server` (enuno) | +|---|---|---| +| Activity | Very active (PyPI release every few days; 116 releases) | Slow (5 releases, last May 2026) | +| Tools | 189 Network tools (stable), 15 categories incl. firewall, clients, devices, stats, VPN | ~130 tools across network areas, broader but less mature | +| Auth model | Local controller login (username/password) | Local API key (`UNIFI_API_KEY`) | +| HTTP transport | Native streamable-http + DNS-rebinding protection + Host allowlist | SSE/streamable-http, no caller auth | +| Safety | Preview-then-confirm for all mutations; policy gates; secret redaction in responses | Standard | + +Local username/password login matches the UniFi controller's local-account +model directly and is the simplest credential Will needs to create (one +dedicated local admin/service account). The response redaction +(Wi-Fi passphrases, VPN keys, API tokens → `***REDACTED***`) keeps controller +secrets out of agent context by default. + +## Updates (fully automatic) + +The build workflow (`.gitea/workflows/build.yaml`) runs: + +1. **Weekly, Monday 06:00 UTC** — resolves the newest published + `unifi-network-mcp` version on PyPI; if no image exists for it yet, builds + and pushes `v` (and `latest`). This lands ~7 hours before the + cluster0 image-update cron (Monday 13:00 UTC), which then bumps the + deployment's image tag exactly like any other app. +2. **On push to `main`** — e.g. after bumping the Dockerfile's default + `UNIFI_NETWORK_MCP_VERSION` ARG. +3. **Manual dispatch** — Actions → build → Run workflow, optionally with an + explicit version. + +Skips automatically when the resolved version is already published. +Registry login uses a dedicated scoped PAT (`REGISTRY_TOKEN` repo Actions +secret, `write:package`) — Gitea's container registry rejects scoped +token requests from the Actions job token (upstream issue #23642). + +## Runtime configuration + +Set at deploy time by the Kubernetes manifests (never baked in): + +| Env | Purpose | +|---|---| +| `UNIFI_HOST` | Controller address (required — server refuses to start without it) | +| `UNIFI_USERNAME` | Local controller admin/service account (from the server's own secret) | +| `UNIFI_PASSWORD` | Password for that account (from the server's own secret) | +| `UNIFI_VERIFY_SSL` | `false` (controller uses a self-signed certificate) | +| `UNIFI_MCP_ALLOWED_HOSTS` | Trusted Host/Origin for the server's DNS-rebinding protection | + +Endpoints: `/mcp` (streamable HTTP; FastMCP default path). No `/health` +endpoint upstream — Kubernetes probes use tcpSocket.