Files
AegisGitea-MCP/docs/deployment.md
T
Latte 59d0e43aae docs: fix the claude mcp add form, pin the package index, document known limitations
The --env option swallowed the server name, so the stdio command is reordered. uvx now always
names the project registry because the package name is not registered on public PyPI.
README gains a Known limitations section, the READ_ONLY wording is corrected, packaging.md
warns about --extra-index-url and the Le Chat admin requirement is mentioned.
2026-10-14 18:01:19 +02:00

8.4 KiB

Deployment

This page covers running the HTTP server for remote clients. For a local stdio setup see getting-started.md. Read hardening.md as well: this server hands the power of its Gitea token to every client that can authenticate.

Choose a mode

  • Admin-token only: set GITEA_TOKEN and MCP_BEARER_TOKEN. Every client with the bearer token acts as the token owner.
  • Per-user OAuth: additionally set OAUTH_ENABLED=true with the OAuth settings. Users sign in with their own Gitea account (after a consent step on /oauth/consent) and act under their own permissions. The static bearer keeps working next to it as the admin identity; leave MCP_BEARER_TOKEN unset to turn that off.
  • OAuth only: OAUTH_ENABLED=true with neither MCP_BEARER_TOKEN nor GITEA_TOKEN. The server starts and serves signed-in users only; admin-token mode does not exist in this deployment, so no request can ever fall back to an admin identity. Recommended for public deployments.

Docker

Image: git.hiddenden.cafe/hiddenden/aegisgitea-mcp. Tag dev is built from the dev branch and latest from main. uv run aegis-gitea-mcp setup prints this compose service, with your port:

services:
  aegis-gitea-mcp:
    image: git.hiddenden.cafe/hiddenden/aegisgitea-mcp:dev
    restart: unless-stopped
    env_file: .env
    environment:
      MCP_TRANSPORT: http
      MCP_HOST: 0.0.0.0  # allowed because authentication is configured
      DATA_DIR: /var/lib/aegis-mcp
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - aegis-mcp-data:/var/lib/aegis-mcp
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

volumes:
  aegis-mcp-data:

Points that matter:

  • Inside the container the server must bind 0.0.0.0 so the published port works. That is allowed only because MCP_BEARER_TOKEN or OAuth is configured; otherwise it exits with code 2.
  • The port is published on 127.0.0.1 only. The reverse proxy on the same host is the public entry point.
  • DATA_DIR must be a persistent volume writable by the container user. It holds the audit log and the encrypted OAuth token store (oauth-store.json, mode 0600). Losing the volume loses all OAuth logins and the audit history. Back it up, and protect the backup like the secrets it protects.
  • Keep .env out of version control and out of the image (mode 0600; setup writes it that way). Compose passes it as environment, so it is visible to docker inspect for anyone with Docker access.

Check it: curl http://127.0.0.1:8080/health.

Reverse proxy and TLS

Terminate TLS at a reverse proxy. PUBLIC_BASE_URL must be the public https URL (it is required for OAuth, and http is only accepted for loopback hosts). Mistral Le Chat needs a valid public certificate, and adding a connector there may require workspace admin rights.

Caddy:

gitea-mcp.example.com {
    reverse_proxy 127.0.0.1:8080 {
        flush_interval -1
    }
}

nginx: inside the proxying location, set proxy_buffering off; so streamed responses are not held back. Also forward Host (proxy_set_header Host $host;) and keep the default read timeout in mind for long tool calls.

The server rejects requests whose Host is not allowed (DNS-rebinding protection). Allowed hosts are the loopback names with the port, the host of PUBLIC_BASE_URL, and ALLOWED_HOSTS. If you reach it under another name, add that name to ALLOWED_HOSTS. With a non-loopback MCP_HOST (the container) and neither PUBLIC_BASE_URL nor ALLOWED_HOSTS, every remote Host is rejected with 421, and the server logs a warning at startup naming the two variables.

The server trusts X-Forwarded-For only from FORWARDED_ALLOW_IPS (default 127.0.0.1). When the proxy reaches the container from another address, for example a Docker network, add that address or network (FORWARDED_ALLOW_IPS=172.18.0.0/16). Without it every request looks like it comes from the proxy and shares one per-IP rate limit bucket. * trusts any peer, so use it only when the port is reachable solely from the proxy.

OAuth application in Gitea

setup --public-base-url https://gitea-mcp.example.com registers the application for you as the admin's user application. To do it by hand: in Gitea open Settings, Applications, Manage OAuth2 Applications, create an application named aegis-gitea-mcp as a confidential client with the redirect URI https://gitea-mcp.example.com/oauth/callback, and put the client id and secret in GITEA_OAUTH_CLIENT_ID and GITEA_OAUTH_CLIENT_SECRET. Then set OAUTH_ENABLED=true, PUBLIC_BASE_URL and TOKEN_ENCRYPTION_KEY (at least 32 characters, openssl rand -hex 32).

Gitea has no instance-wide OAuth application API, so the application belongs to the admin account that created it. Re-running setup reuses it; if the stored client secret is missing, Gitea has to regenerate it, and setup asks before doing so.

Before the browser is sent to Gitea, the consent page /oauth/consent shows the client name and the redirect URI, because Gitea remembers consent for this one application and any client can register itself. For a public deployment also set OAUTH_REDIRECT_ALLOWLIST to the hosted clients you use, for example https://claude.ai/,https://chat.mistral.ai/ (see configuration.md).

OAUTH_SCOPES is the scope list sent to Gitea. Keep it to granular scopes. Narrow it (for example to read:repository,read:issue) to limit what signed-in users' tokens can do through this server regardless of their Gitea rights.

Upgrades

  • Application: pull the new image (or package) and restart. The data volume is kept.

  • TOKEN_ENCRYPTION_KEY must stay the same. A store written with another key stops the server at startup (exit code 2, the file is kept) instead of being wiped: restore the old key, or move DATA_DIR/oauth-store.json away to start over, which signs every OAuth user out.

  • Upgrading Gitea: tool names and schemas come from the pinned swagger, so a Gitea upgrade does not change them. To pick up a newer API, re-pin the swagger and regenerate the catalog:

    curl -fsS https://git.example.com/swagger.v1.json -o spec/gitea-swagger.v1.json
    uv run python scripts/generate_catalog.py
    uv run python scripts/check_docs.py --write
    uv run pytest
    

    Review the diff of tools.json and docs/tools.md: renamed or removed operations change tool names. Until the new spec is pinned, gitea_api_request reaches new endpoints.

CI

The workflows in .gitea/workflows/ lint, test and build. The docs-check job runs uv run python scripts/check_docs.py, which fails when docs/tools.md or the README tool summary is stale, or when a document names a tool that does not exist. scripts/generate_catalog.py --check fails when tools.json does not match the pinned swagger.

Security rules of the pipeline:

  • Jobs that use the runner's Docker daemon or a secret (test-integration, test-mcp, build-and-push) do not run for pull requests from forks. Access to the daemon is root-equivalent on the host, and a fork controls the workflow and test code. Also enable "require approval for workflows from forks" (or the equivalent runner setting) in Gitea, and keep the daemon of a shared runner free of services you cannot afford to lose.
  • Every third-party action is pinned to a full commit SHA with the version in a trailing comment. Bump a pin deliberately: look up the SHA of the new tag with git ls-remote https://github.com/<owner>/<action> refs/tags/<tag> (add ^{} for annotated tags). The registry token never reaches a third-party action: the CLI reads it from stdin and uv publish reads UV_PUBLISH_PASSWORD.
  • build-and-push scans the built image with a pinned Trivy (scripts/ci/install_trivy.sh, sha256 verified) before anything is pushed, and fails on HIGH or CRITICAL findings that have a fix. scan.yml repeats the scan and the dependency audit every week, so a new advisory in a pinned base image turns it red: refresh the digests in docker/Dockerfile. Accepted findings go in security/trivyignore.txt with a waiver row in security.md.
  • publish.yml needs the security job (secret scan, security tests, dependency audit, bandit rules) as well as lint and tests.
  • The cleanup script removes containers only for per-run image names (aegis-ci-<run id>-...) and immutable :sha- tags. Shared tags (:latest, :main, :dev) are only untagged.