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