157 lines
7.5 KiB
Markdown
157 lines
7.5 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 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.
|
|
|
|
## 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-tokens.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.
|
|
|
|
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.
|
|
|
|
`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, or stored OAuth logins can no longer be decrypted
|
|
and users have to sign in again.
|
|
- 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.
|