Files
AegisGitea-MCP/docs/deployment.md
T

172 lines
8.7 KiB
Markdown

# Deployment
This page covers running the HTTP server for remote clients. For a local stdio setup see
[getting-started.md](getting-started.md). Read [hardening.md](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:
```yaml
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](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:
```bash
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](security.md).
- `publish.yml` needs the `security` job (secret scan, security tests, dependency audit, bandit
rules) as well as lint and tests.
- The image is built under a per-run local tag (`aegis-ci-<run id>-build:<sha>`), scanned under
that tag, and the registry tags (`:sha-<12>`, `:dev`, `:latest`, `:main`) are applied only
immediately before the push. `build-and-push` needs `lint`, `test-unit`, `test-integration`,
`test-mcp` and `security`, so `:dev` and `:latest` are never pushed while one is red.
- The cleanup script removes containers only for per-run image names (`aegis-ci-<run id>-...`).
Registry tags, the `:sha-` tag included, are only untagged.