Skip to content

Commit 9c247df

Browse files
Dumbrisclaude
andauthored
ci: re-enable the server Docker image build on stable releases (#1208)
* ci: re-enable the server Docker image build on stable releases The `build-docker` job has been gated behind `if: false` since the Teams work; the server edition it builds now ships, so nothing is holding it back. Re-enable it and make `release` depend on it, so a stable tag never publishes binaries without a matching container image. Deviations from the issue's proposal, and why: - The guard is `startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')`, not the bare tag check. release.yml fires on every `v*` tag including RCs, and the job pushes `:latest` unconditionally — an RC tag must not move that pointer. This matches the guard `build`/`release` already use. - The image is now multi-arch (`linux/amd64,linux/arm64`). The release already ships an arm64 Linux binary, and container users on Raspberry Pi / Graviton would otherwise be stuck building it themselves — the exact problem the issue reports. The Dockerfile cross-compiles from `$BUILDPLATFORM` via `$TARGETARCH`, so this is a second native build rather than QEMU emulation. - Added a build-only `docker-image` job to PR Build. Now that `release` hard-depends on `build-docker`, a Dockerfile regression would otherwise first surface at tag time and block the release; this catches it on the PR instead, and smoke-tests the entrypoint. Verified locally: both the single-arch and the multi-arch build succeed, the amd64 image reports `(server) linux/amd64`, `/healthz` returns 200, and `/ui/` serves the real embedded Vue bundle (not the fallback stub). Closes #1171 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ci): gate build-docker on qa-gate before it pushes to GHCR cmd/release-gate's TestPublishJobsGatedOnQAGate caught this on the first CI run: the audit skips jobs that can never run (`if: false`), so un-gating build-docker made it visible as a publisher for the first time — and its `needs: [build]` closure did not reach the qa-gate job. A tag whose release qualification gate failed would still have pushed an image to GHCR, `:latest` pointer included. `needs: [build, qa-gate]` closes it. No cycle: release -> build-docker -> qa-gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci: label the server image, and warn about the data-dir traps Two things a deep audit of the server edition turned up that bear directly on this PR: 1. Flipping `if: false` is not sufficient to close #1171. A GHCR package first created by GITHUB_TOKEN is PRIVATE by default, and this job had no labels and no visibility step. An anonymous registry probe today returns 403 for mcpproxy-server — identical to the control probe for a name that does not exist — while all five scanner sidecars return 200. So after the first stable tag, the `docker run` command this PR adds to the install docs would fail with `denied` for the person who filed the issue. `org.opencontainers.image.source` links the package to this repo, which is a prerequisite for the package page and README, but it does NOT make it public — that is a one-time manual flip in the org's package settings after the first push. The comment says so, so the next person does not have to rediscover it. 2. The install docs now warn about the data-dir traps. `MCPPROXY_DATA` and a bare `--data-dir` both silently wipe the persisted config on every boot (the override is applied after config resolution, so the config in the data dir is never read and is then overwritten with a fresh default, rotating the API key). `MCPPROXY_DATA_DIR`, documented elsewhere as the preferred form, has no implementation at all. Fixing the loader ordering is a separate change; documenting the trap is not. Also clarified that state dies on container REPLACEMENT, not restart — verified: `docker restart` on the same container reads back the same key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent e9562a0 commit 9c247df

5 files changed

Lines changed: 107 additions & 9 deletions

File tree

‎.github/workflows/pr-build.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,39 @@ jobs:
388388
path: mcpproxy-*-darwin-${{ matrix.goarch }}-installer.dmg
389389
retention-days: 14
390390
if-no-files-found: warn
391+
392+
# The release workflow's `release` job now depends on `build-docker`, so a
393+
# Dockerfile that no longer builds blocks a whole release at tag time. Build
394+
# (never push) the server image on every PR so breakage surfaces here instead.
395+
# Single-platform amd64 — it runs natively on the runner, which keeps this a
396+
# couple of minutes and lets us smoke-test the entrypoint.
397+
docker-image:
398+
name: Build Server Docker Image
399+
needs: [prepare]
400+
runs-on: ubuntu-latest
401+
steps:
402+
- name: Checkout
403+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
404+
405+
- name: Set up Buildx
406+
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
407+
408+
- name: Build image
409+
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
410+
with:
411+
context: .
412+
push: false
413+
load: true
414+
platforms: linux/amd64
415+
build-args: |
416+
VERSION=${{ needs.prepare.outputs.version }}
417+
COMMIT=${{ github.sha }}
418+
tags: mcpproxy-server:pr
419+
cache-from: type=gha
420+
cache-to: type=gha,mode=max
421+
422+
- name: Smoke-test the image
423+
run: |
424+
set -eo pipefail
425+
docker run --rm --entrypoint mcpproxy mcpproxy-server:pr version | tee /tmp/version.txt
426+
grep -q '(server)' /tmp/version.txt

‎.github/workflows/release.yml‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,9 +1231,15 @@ jobs:
12311231

12321232
build-docker:
12331233
runs-on: ubuntu-latest
1234-
needs: [build]
1235-
# Disabled until server MVP is ready — uncomment to enable
1236-
if: false && startsWith(github.ref, 'refs/tags/v')
1234+
# `qa-gate` is load-bearing, not cosmetic: this job pushes to GHCR (including
1235+
# the `:latest` pointer), which is publishing to users. Without it in the
1236+
# closure a tag whose gate failed would still ship a container image — the
1237+
# exact hole cmd/release-gate's TestPublishJobsGatedOnQAGate exists to catch.
1238+
needs: [build, qa-gate]
1239+
# Stable releases only, matching `build`/`release` — release.yml fires on every
1240+
# `v*` tag, and an RC must never move `mcpproxy-server:latest`. prerelease.yml
1241+
# owns the RC channel and publishes no image.
1242+
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
12371243
permissions:
12381244
contents: read
12391245
packages: write
@@ -1249,13 +1255,28 @@ jobs:
12491255
with:
12501256
context: .
12511257
push: true
1258+
# linux/arm64 matters for Raspberry Pi / Graviton hosts, and the release
1259+
# already ships an arm64 Linux binary. The Dockerfile cross-compiles from
1260+
# $BUILDPLATFORM, so this costs a second native build, not QEMU emulation.
1261+
platforms: linux/amd64,linux/arm64
12521262
build-args: |
12531263
VERSION=${{ github.ref_name }}
12541264
COMMIT=${{ github.sha }}
12551265
BUILD_DATE=${{ github.event.head_commit.timestamp }}
12561266
tags: |
12571267
ghcr.io/smart-mcp-proxy/mcpproxy-server:${{ github.ref_name }}
12581268
ghcr.io/smart-mcp-proxy/mcpproxy-server:latest
1269+
# image.source links the GHCR package back to this repo so it inherits the
1270+
# repo's README and shows up under the org's packages. It does NOT make the
1271+
# package public: a package first created by GITHUB_TOKEN is private, and
1272+
# visibility has to be flipped once by hand after the first push. Until that
1273+
# happens an anonymous `docker pull` gets `denied`.
1274+
labels: |
1275+
org.opencontainers.image.source=https://github.com/smart-mcp-proxy/mcpproxy-go
1276+
org.opencontainers.image.description=MCPProxy Server Edition — headless MCP proxy with tool discovery and quarantine
1277+
org.opencontainers.image.licenses=MIT
1278+
org.opencontainers.image.revision=${{ github.sha }}
1279+
org.opencontainers.image.version=${{ github.ref_name }}
12591280
12601281
# Spec 081: release qualification gate. Runs the server-type matrix +
12611282
# invariants + assembled suites and produces one pass/fail verdict. The
@@ -1271,7 +1292,7 @@ jobs:
12711292
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
12721293

12731294
release:
1274-
needs: [build, sign-windows, generate-notes, qa-gate] # add build-docker when server MVP is ready
1295+
needs: [build, build-docker, sign-windows, generate-notes, qa-gate]
12751296
runs-on: ubuntu-latest
12761297
environment: production
12771298
# Explicit guard (belt-and-suspenders on top of the cascade from needs:) — RC tags

‎Dockerfile‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
# Build stage
2-
FROM golang:1.27-alpine AS builder
1+
# Build stage. Pinned to $BUILDPLATFORM and cross-compiled via $TARGETARCH so a
2+
# multi-arch build never needs QEMU emulation (the binary is CGO_ENABLED=0 and the
3+
# frontend is arch-independent).
4+
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS builder
35

46
RUN apk add --no-cache git nodejs npm make
57

@@ -17,7 +19,9 @@ RUN mkdir -p web/frontend && cp -r frontend/dist web/frontend/
1719
ARG VERSION=dev
1820
ARG COMMIT=unknown
1921
ARG BUILD_DATE=unknown
20-
RUN CGO_ENABLED=0 go build \
22+
ARG TARGETOS
23+
ARG TARGETARCH
24+
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build \
2125
-tags server \
2226
-ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE} -X github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi.buildVersion=${VERSION} -X github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck.buildChannel=docker -s -w" \
2327
-o /mcpproxy ./cmd/mcpproxy

‎docs/getting-started/installation.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,43 @@ You can then run `mcpproxy serve` directly, or wire up your own systemd unit mod
365365

366366
The systemd unit launches mcpproxy with `--config=/etc/mcpproxy/mcp_config.json --data-dir=/var/lib/mcpproxy` and uses `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp`, and friends.
367367

368+
## Docker (Server edition)
369+
370+
The Server edition is published as a multi-arch image (`linux/amd64`, `linux/arm64`) on
371+
every stable release:
372+
373+
```bash
374+
docker run -d --name mcpproxy \
375+
-p 127.0.0.1:8080:8080 \
376+
-e MCPPROXY_API_KEY="$(openssl rand -hex 32)" \
377+
-v mcpproxy-data:/root/.mcpproxy \
378+
ghcr.io/smart-mcp-proxy/mcpproxy-server:latest
379+
```
380+
381+
- **Tags**: `ghcr.io/smart-mcp-proxy/mcpproxy-server:<version>` (e.g. `v0.64.0`) and `:latest`,
382+
which always points at the newest stable release. RC builds publish no image.
383+
- **State survives restarts, not replacement**: `docker restart` keeps everything. Removing and
384+
re-running the container, upgrading the tag, or rescheduling the pod destroys whatever is not
385+
on the volume — so mount one before you configure anything.
386+
- **Don't use `MCPPROXY_DATA` or a bare `--data-dir` to relocate state.** The data-dir override is
387+
applied after the config file is resolved, so the config in that directory is never read and is
388+
overwritten with a fresh default (rotating the API key) on every boot. Mount the volume at the
389+
default `/root/.mcpproxy` path instead. (`MCPPROXY_DATA_DIR`, referenced elsewhere in the docs,
390+
is not implemented at all.)
391+
- **Port**: the entrypoint is `mcpproxy serve --listen 0.0.0.0:8080`, so the container listens on
392+
`8080` inside the network namespace. The `-p 127.0.0.1:8080:8080` above keeps it reachable only
393+
from the host; drop the `127.0.0.1:` prefix only if you deliberately want it on the network, and
394+
read [Network exposure: localhost by default](#network-exposure-localhost-by-default) first.
395+
- **State**: config, the BBolt DB, and the search index live in `/root/.mcpproxy`. Mount a volume
396+
there or every restart starts from an empty config.
397+
- **API key**: the REST API and Web UI require one. Set `MCPPROXY_API_KEY` explicitly and keep a
398+
copy — the image is distroless (no shell), so reading an auto-generated key back out of the
399+
container is awkward.
400+
- **Web UI**: `http://localhost:8080/ui/`.
401+
402+
Note that this image ships the Server edition binary (`mcpproxy version` reports `(server)`); it is
403+
the headless core only, with no system tray.
404+
368405
## Verify Installation
369406

370407
After installation, verify MCPProxy is working:

‎docs/mcp-registry-publishing.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,9 @@ mcp-publisher status --status deleted \
9898
## Registry Schema Notes
9999

100100
- `version` must be a plain semantic version string (`0.33.1`) — no `v` prefix, no ranges.
101-
- `packages[]` is empty because none of the supported `registryType` values (`npm`, `pypi`, `oci`, `nuget`, `mcpb`) match MCPProxy's distribution channels (Homebrew tap, GitHub release tarballs, `.deb`/`.rpm`, `go install`). The Docker server-edition image at `ghcr.io/smart-mcp-proxy/mcpproxy-server` is not yet published to releases (gated behind `if: false` in the release workflow).
101+
- `packages[]` is empty because none of the supported `registryType` values (`npm`, `pypi`, `oci`, `nuget`, `mcpb`) match MCPProxy's distribution channels (Homebrew tap, GitHub release tarballs, `.deb`/`.rpm`, `go install`). The Docker server-edition image at `ghcr.io/smart-mcp-proxy/mcpproxy-server` *is* published on every stable tag, but the server edition speaks Streamable HTTP rather than stdio, so it is not a drop-in `oci` package entry either.
102102
- `remotes[]` is intentionally **omitted**. mcpproxy runs locally (`mcpproxy serve` exposes Streamable HTTP at `http://localhost:8080/mcp`), but the registry's semantic validation **rejects localhost/private remote URLs** (`invalid-remote-url`) — `remotes[]` is reserved for publicly reachable hosted endpoints. With no public remote and no installable package type, the entry is **discovery-only**: it lists the server, description, and repository link so clients can find it and follow the repo's install instructions.
103-
- If a Docker image for the server edition is published in a future release, add an `oci` entry to `packages[]` pointing at `ghcr.io/smart-mcp-proxy/mcpproxy-server:{version}` with `"transport": {"type": "stdio"}` and the appropriate `runtimeArguments` for `docker run`. That would upgrade the entry from discovery-only to one-click installable.
103+
- Adding an `oci` entry to `packages[]` for `ghcr.io/smart-mcp-proxy/mcpproxy-server:{version}` would upgrade the entry from discovery-only to one-click installable, but it needs a transport the registry accepts for a container that serves HTTP on a published port — not `"transport": {"type": "stdio"}`. Revisit once the registry's remote/container transport story covers it.
104104

105105
## References
106106

0 commit comments

Comments
 (0)