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.
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_TOKENandMCP_BEARER_TOKEN. Every client with the bearer token acts as the token owner. - Per-user OAuth: additionally set
OAUTH_ENABLED=truewith 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; leaveMCP_BEARER_TOKENunset to turn that off. - OAuth only:
OAUTH_ENABLED=truewith neitherMCP_BEARER_TOKENnorGITEA_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.0so the published port works. That is allowed only becauseMCP_BEARER_TOKENor OAuth is configured; otherwise it exits with code 2. - The port is published on
127.0.0.1only. The reverse proxy on the same host is the public entry point. DATA_DIRmust 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
.envout of version control and out of the image (mode 0600;setupwrites it that way). Compose passes it as environment, so it is visible todocker inspectfor 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_KEYmust 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 moveDATA_DIR/oauth-store.jsonaway 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 pytestReview the diff of
tools.jsonanddocs/tools.md: renamed or removed operations change tool names. Until the new spec is pinned,gitea_api_requestreaches 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 anduv publishreadsUV_PUBLISH_PASSWORD. build-and-pushscans 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.ymlrepeats the scan and the dependency audit every week, so a new advisory in a pinned base image turns it red: refresh the digests indocker/Dockerfile. Accepted findings go insecurity/trivyignore.txtwith a waiver row in security.md.publish.ymlneeds thesecurityjob (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.