From 8416babf19f5cfedcef70619ac3242f1a6a15a6c Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 27 Sep 2026 13:01:25 +0100 Subject: [PATCH] Initial gitea-mcpo-docker image Wraps the official gitea-mcp server (binary copied from docker.gitea.com/gitea-mcp-server:1.7.0) and serves it two ways from one container: - :8000 mcpo (OpenAPI) for Open WebUI, using the container's service token and guarded by MCPO_API_KEY. - :8080 native streamable-HTTP MCP at /mcp for coding agents, with the service token stripped so each client must send its own Gitea token. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/docker-build.yml | 42 +++++++++++++ Dockerfile | 47 ++++++++++++++ README.md | 100 ++++++++++++++++++++++++++++++ entrypoint.sh | 75 ++++++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 .gitea/workflows/docker-build.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 entrypoint.sh diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml new file mode 100644 index 0000000..b21e2d9 --- /dev/null +++ b/.gitea/workflows/docker-build.yml @@ -0,0 +1,42 @@ +name: Build and push image + +on: + push: + branches: + - main + workflow_dispatch: + +env: + IMAGE: code.aneur.in/cloud/gitea-mcpo-docker + +jobs: + build: + 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 code.aneur.in registry + uses: docker/login-action@v3 + with: + registry: code.aneur.in + username: ${{ gitea.actor }} + password: ${{ secrets.BUILD_API_TOKEN }} + + - name: Promote current "latest" to "previous" + run: | + if docker buildx imagetools inspect "$IMAGE:latest" >/dev/null 2>&1; then + docker buildx imagetools create -t "$IMAGE:previous" "$IMAGE:latest" + else + echo "No existing :latest to promote; skipping." + fi + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: code.aneur.in/${{ gitea.repository }}:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f788891 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1 + +# Pinned release of the official Gitea MCP server +# (https://gitea.com/gitea/gitea-mcp), taken from its published image rather +# than built from source. Note the registry's tags have no "v" prefix. Bump +# by checking the new release's `gitea-mcp --help` still accepts the flags +# and env vars entrypoint.sh relies on (-t stdio|http, -p, GITEA_HOST, +# GITEA_ACCESS_TOKEN[_FILE], MCP_MODE). +ARG GITEA_MCP_VERSION=1.7.0 + +# Pinned mcpo release (PyPI). Bump by checking the new version's CLI flags +# still match what entrypoint.sh assumes (--host/--port/--api-key/--). +ARG MCPO_VERSION=0.0.20 + +# mcpo's own pyproject.toml only requires "mcp>=1.17.0" (unbounded), but its +# lockfile shows it's built/tested against exactly this version. Pinning it +# ourselves avoids pip resolving a newer mcp SDK release whose API mcpo +# doesn't yet support (observed: mcp 2.2.0 renamed an import mcpo 0.0.20 +# depends on, breaking startup). When bumping MCPO_VERSION, check the new +# release's uv.lock for its actual tested "mcp" version and update this too. +ARG MCP_SDK_VERSION=1.17.0 + +FROM docker.gitea.com/gitea-mcp-server:${GITEA_MCP_VERSION} AS gitea-mcp + +FROM python:3.12-slim-bookworm +ARG MCPO_VERSION +ARG MCP_SDK_VERSION + +# tini as PID 1: entrypoint.sh supervises two long-running processes, so +# something needs to reap zombies and deliver signals properly. +RUN apt-get update && apt-get install -y --no-install-recommends tini \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir "mcpo==${MCPO_VERSION}" "mcp==${MCP_SDK_VERSION}" + +# gitea-mcp is a static Go binary (CGO_ENABLED=0), so it runs fine outside +# its distroless origin image. +COPY --from=gitea-mcp /app/gitea-mcp /usr/local/bin/gitea-mcp + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +RUN useradd --system --no-create-home --shell /usr/sbin/nologin app +USER app + +EXPOSE 8000 8080 +ENTRYPOINT ["tini", "--", "/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..748683b --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# gitea-mcpo-docker + +A single Docker image that exposes the official [Gitea MCP server](https://gitea.com/gitea/gitea-mcp) +in two ways at once: + +| Port | Protocol | For | Gitea identity | +|---|---|---|---| +| `8000` | OpenAPI, via [`mcpo`](https://github.com/open-webui/mcpo) | Open WebUI | The container's service token (`GITEA_ACCESS_TOKEN`) | +| `8080` | Native MCP (streamable HTTP) at `/mcp` | Coding agents (Claude Code etc.) | Each client's own token, sent as `Authorization: Bearer ` | + +Both are served by the same `gitea-mcp` binary: `mcpo` spawns it as a stdio child, +and a second instance runs in `gitea-mcp`'s own HTTP mode. + +## Build + +```sh +docker build -t gitea-mcpo-docker . +``` + +## Run + +```sh +docker run -p 8000:8000 -p 8080:8080 \ + -e GITEA_HOST=https://code.aneur.in \ + -e GITEA_ACCESS_TOKEN= \ + -e MCPO_API_KEY=some-secret \ + gitea-mcpo-docker +``` + +### Open WebUI + +Add a tool server pointing at `http://:8000`, with bearer auth set to +`MCPO_API_KEY`. Interactive API docs are at `http://:8000/docs` (and +`/openapi.json`). These are served without authentication even when `MCPO_API_KEY` +is set; only the tool endpoints themselves require it. + +### Claude Code (or any MCP client) + +```sh +claude mcp add --transport http gitea http://:8080/mcp \ + --header "Authorization: Bearer " +``` + +The HTTP endpoint never falls back to the service token: `GITEA_ACCESS_TOKEN` and +`GITEA_ACCESS_TOKEN_FILE` are stripped from that process's environment. A request +without a token gets a `token is required` tool error, and a request with one acts +as that token's user. Commits, comments and PRs are therefore attributed to whichever +account the client's token belongs to. + +## Environment variables + +| Variable | Effect | +|---|---| +| `GITEA_HOST` | **Required.** Base URL of the Gitea instance, e.g. `https://code.aneur.in`. | +| `GITEA_ACCESS_TOKEN` | Token that the mcpo side (Open WebUI) uses. Not visible to the `:8080` MCP endpoint. | +| `GITEA_ACCESS_TOKEN_FILE` | Alternative to the above: path to a file holding the token (e.g. a Docker secret). | +| `MCPO_API_KEY` | If set, mcpo tool endpoints require `Authorization: Bearer `. If unset, mcpo serves unauthenticated. | +| `GITEA_READONLY` | `true` exposes only read-only tools, on **both** ports. | +| `GITEA_SCOPES` / `GITEA_TOOLS` | Comma-separated allowlist of tool scopes / tool names, on both ports (see `gitea-mcp --help`). | +| `GITEA_INSECURE` | `true` ignores TLS certificate errors talking to Gitea. | +| `GITEA_DEBUG` | `true` enables `gitea-mcp` debug logging. | +| `MCPO_PORT` | Port mcpo listens on. Default `8000`. | +| `MCP_PORT` | Port the native MCP endpoint listens on. Default `8080`. | +| `ENABLE_MCPO` / `ENABLE_MCP` | Set either to `false` to run only the other side. Both default `true`. | + +`MCP_MODE` is ignored: `gitea-mcp` would otherwise let it override the transport, +so the entrypoint unsets it. + +## Process supervision + +`entrypoint.sh` runs both servers under `tini`. If either one exits, the other is +stopped and the container exits non-zero, so the orchestrator restarts the whole +thing rather than leaving it half up. `docker stop` shuts both down promptly. + +## Known characteristics + +- **No TLS termination.** This image serves plain HTTP; put a reverse proxy in front + of it if you need TLS or network restriction. +- **The `:8080` endpoint has no auth of its own.** It relies entirely on Gitea tokens: + anyone who can reach it and holds a valid Gitea token can use it as that user, + and no more than that user could do directly against the Gitea API. +- `gitea-mcp`'s HTTP endpoint also serves `/healthz` (unauthenticated). + +## Pinned revisions + +- `gitea-mcp`: pinned via `ARG GITEA_MCP_VERSION` in the `Dockerfile`. The binary is + copied from the official image `docker.gitea.com/gitea-mcp-server` (tags have no + `v` prefix). When bumping it, check `gitea-mcp --help` still accepts what + `entrypoint.sh` uses. +- `mcpo`: pinned via `ARG MCPO_VERSION`, with the `mcp` SDK pinned alongside it via + `ARG MCP_SDK_VERSION` (see the comment in the `Dockerfile` for why). + +## CI + +`.gitea/workflows/docker-build.yml` builds and pushes +`code.aneur.in/cloud/gitea-mcpo-docker:latest` on every push to `main`, first +re-tagging the existing `:latest` as `:previous`. It logs in to the registry as the +triggering user, with the repo secret `BUILD_API_TOKEN` as the password. The default +Gitea Actions token can't write to the package registry on this instance, so that +secret must be a personal access token with package-write scope. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..a4b518d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,75 @@ +#!/bin/sh +set -eu + +# Runs up to two servers in one container, both backed by the same +# gitea-mcp binary: +# +# - mcpo (OpenAPI, for Open WebUI) on $MCPO_PORT, wrapping a stdio +# `gitea-mcp` child. That child authenticates to Gitea with the +# container's GITEA_ACCESS_TOKEN (or GITEA_ACCESS_TOKEN_FILE). No +# passthrough is needed: mcpo builds the spawned child's environment as +# {**os.environ, **extra}, so it inherits GITEA_HOST, GITEA_READONLY etc. +# +# - gitea-mcp's own streamable-HTTP MCP endpoint (for coding clients) on +# $MCP_PORT, path /mcp. This one is deliberately started WITHOUT the +# service token, so every client must send its own Gitea token as +# `Authorization: Bearer `; without one, tool calls fail with +# "token is required" rather than silently acting as the service user. +# +# mcpo's --api-key is a CLI flag, not something it reads from its own +# environment, so the command line is built here at container start. + +: "${GITEA_HOST:?GITEA_HOST must be set, e.g. https://code.aneur.in}" + +MCPO_PORT="${MCPO_PORT:-8000}" +MCP_PORT="${MCP_PORT:-8080}" +ENABLE_MCPO="${ENABLE_MCPO:-true}" +ENABLE_MCP="${ENABLE_MCP:-true}" + +# gitea-mcp lets MCP_MODE override its -t flag; make sure it can't. +unset MCP_MODE + +pids="" + +shutdown() { + # shellcheck disable=SC2086 + [ -n "$pids" ] && kill -TERM $pids 2>/dev/null || true +} +trap 'shutdown; wait; exit 143' TERM INT + +if [ "$ENABLE_MCP" = "true" ]; then + env -u GITEA_ACCESS_TOKEN -u GITEA_ACCESS_TOKEN_FILE \ + gitea-mcp -t http -p "$MCP_PORT" & + pids="$pids $!" +fi + +if [ "$ENABLE_MCPO" = "true" ]; then + if [ -n "${MCPO_API_KEY:-}" ]; then + mcpo --host 0.0.0.0 --port "$MCPO_PORT" --api-key "$MCPO_API_KEY" -- gitea-mcp -t stdio & + else + mcpo --host 0.0.0.0 --port "$MCPO_PORT" -- gitea-mcp -t stdio & + fi + pids="$pids $!" +fi + +if [ -z "$pids" ]; then + echo "Both ENABLE_MCPO and ENABLE_MCP are false; nothing to run." >&2 + exit 1 +fi + +# Exit as soon as either server dies, taking the other down with it, so the +# orchestrator restarts the whole container rather than leaving it half-up. +# POSIX sh has no `wait -n`, so poll instead. +while :; do + for pid in $pids; do + if ! kill -0 "$pid" 2>/dev/null; then + status=0 + wait "$pid" || status=$? + echo "Process $pid exited (status $status); stopping container." >&2 + shutdown + wait || true + exit "$(( status == 0 ? 1 : status ))" + fi + done + sleep 1 +done