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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
+47
@@ -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"]
|
||||||
@@ -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 <token>` |
|
||||||
|
|
||||||
|
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=<service-account token> \
|
||||||
|
-e MCPO_API_KEY=some-secret \
|
||||||
|
gitea-mcpo-docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Open WebUI
|
||||||
|
|
||||||
|
Add a tool server pointing at `http://<host>:8000`, with bearer auth set to
|
||||||
|
`MCPO_API_KEY`. Interactive API docs are at `http://<host>: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://<host>:8080/mcp \
|
||||||
|
--header "Authorization: Bearer <your Gitea token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <value>`. 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.
|
||||||
@@ -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 <token>`; 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
|
||||||
Reference in New Issue
Block a user