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

168 lines
8.4 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 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.