unifi-network-mcp image: Dockerfile + Gitea Actions build workflow + README
build / build (push) Failing after 37s
build / build (push) Failing after 37s
This commit is contained in:
@@ -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"
|
||||||
+42
@@ -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<version> (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"]
|
||||||
@@ -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<version>` (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.
|
||||||
Reference in New Issue
Block a user