Files
AegisGitea-MCP/README.md
T
Latte b1bc726a95
docker / test (push) Successful in 29s
docker / lint (push) Successful in 34s
test / test (push) Successful in 32s
lint / lint (push) Successful in 36s
docker / docker-test (push) Successful in 8s
docker / docker-publish (push) Successful in 6s
fix: keep OAuth flow working on read-only container roots
The DCR client registry created its storage directory eagerly in __init__,
and DCR_STORAGE_PATH defaulted to /var/lib/aegis-mcp — a path that is neither
created in the image nor mounted as a writable volume. Under the hardened
read-only docker-compose, every /oauth/authorize, /oauth/token, and /register
call hit `mkdir('/var/lib/aegis-mcp')` on a read-only filesystem, raising an
unhandled OSError and returning a bare "Internal Server Error" during login.

- oauth_flow.py: defer the storage-dir mkdir from __init__ to _persist (the
  only write path). authorize/token only read the registry, so they no longer
  require a writable filesystem and stop 500-ing.
- docker/Dockerfile: create and chown /var/lib/aegis-mcp.
- docker-compose.yml + docker/docker-compose.yml: add a persistent
  aegis-mcp-data volume mounted at /var/lib/aegis-mcp so DCR registrations
  survive restarts.
- .env.example: document DCR_STORAGE_PATH and set PUBLIC_BASE_URL to the real
  MCP host.
- README.md: spell out exact values (Gitea host, MCP host, callback URL, MCP
  URL) and add a "required writable volumes" section explaining the cause of
  the login 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:28:52 +02:00

209 lines
7.8 KiB
Markdown

# AegisGitea-MCP
Security-first MCP server for self-hosted Gitea with per-user OAuth2/OIDC authentication for Claude, Claude Code, and Cowork.
AegisGitea-MCP exposes MCP tools over Streamable HTTP and a legacy SSE alias. Each user authenticates with Gitea through OAuth2/OIDC; repository authorization is checked per user before any service PAT call is allowed.
## Securing MCP with Gitea OAuth
This guide uses the live deployment values as the running example:
| Thing | Value |
|-------|-------|
| Gitea instance (`GITEA_URL`) | `https://git.hiddenden.cafe` |
| This MCP server (`PUBLIC_BASE_URL`) | `https://gitea-mcp.hiddenden.cafe` |
| OAuth callback to register in Gitea | `https://gitea-mcp.hiddenden.cafe/oauth/callback` |
| MCP URL you give to Claude | `https://gitea-mcp.hiddenden.cafe/mcp` |
Substitute your own hostnames if they differ. The two URLs are **different hosts**: `git.*` is Gitea, `gitea-mcp.*` is this proxy.
### 1) Create a Gitea OAuth2 application
1. Open `https://git.hiddenden.cafe/user/settings/applications` (or admin application settings).
2. Create an OAuth2 app.
3. Set the redirect URI to **this MCP server's callback** (not Gitea's own host):
`https://gitea-mcp.hiddenden.cafe/oauth/callback`
This is the only redirect URI Gitea needs — the MCP server forwards each client's real callback through a signed state parameter.
4. Save the app and copy the generated `Client ID` and `Client Secret`.
Required scopes:
- `read:repository`
- `write:repository` (only needed when using write tools)
### 2) Configure this MCP server
```bash
cp .env.example .env
```
Fill in exactly these values in `.env` (everything else has safe defaults):
```env
# The Gitea instance this server talks to
GITEA_URL=https://git.hiddenden.cafe
# Per-user OAuth mode (recommended)
OAUTH_MODE=true
GITEA_OAUTH_CLIENT_ID=<client-id-from-step-1>
GITEA_OAUTH_CLIENT_SECRET=<client-secret-from-step-1>
# Public URL of THIS server (no trailing slash). Claude's MCP URL is this + /mcp
PUBLIC_BASE_URL=https://gitea-mcp.hiddenden.cafe
# Secret that signs the OAuth proxy state. Generate with: openssl rand -hex 32
OAUTH_STATE_SECRET=<random-32-byte-minimum-secret>
# Where dynamically-registered OAuth clients are stored — MUST be a writable,
# persistent path. The default matches the aegis-mcp-data volume in compose.
DCR_STORAGE_PATH=/var/lib/aegis-mcp/dcr_clients.json
```
`GITEA_TOKEN` is optional in OAuth mode. Without it, Gitea REST calls use the user's OAuth access token directly, so Gitea enforces permissions on every API call. With it, the token acts as a service PAT for API execution, but the MCP server first checks the requesting user's permission on the target repository through Gitea and denies the call if the user lacks the required read/write permission.
### 2a) Required writable volumes (read-only container)
The provided `docker-compose.yml` runs the container with a **read-only root filesystem**. The server therefore needs two writable volumes, both already wired up in compose:
| Path | Purpose | Volume |
|------|---------|--------|
| `/var/log/aegis-mcp` | tamper-evident audit log | `aegis-mcp-logs` |
| `/var/lib/aegis-mcp` | dynamic client registration store (`DCR_STORAGE_PATH`) | `aegis-mcp-data` |
If `/var/lib/aegis-mcp` is **not** writable/persistent, the OAuth `authorize`, `token`, and `register` endpoints fail and the browser shows a bare `Internal Server Error` during login. Keep the `aegis-mcp-data` volume mounted (or point `DCR_STORAGE_PATH` at another writable, persistent location), and make sure it survives restarts so registered clients are not lost.
### 3) Configure Claude, Claude Code, or Cowork
Claude's hosted, desktop, mobile, Claude Code, and Cowork surfaces share the same remote MCP connector infrastructure. There is no Claude-specific server code path.
In claude.ai:
1. Open **Settings > Connectors**.
2. Choose **Add custom connector**.
3. Paste `https://gitea-mcp.hiddenden.cafe/mcp`.
4. Complete the OAuth consent flow. Dynamic Client Registration (`/register`) handles Claude client registration.
In Claude Code:
```bash
claude mcp add --transport http aegis-gitea https://gitea-mcp.hiddenden.cafe/mcp
```
Cowork uses the same connector model and MCP URL.
Manual OAuth client configuration remains available for clients that do not use DCR:
- MCP server URL: `https://gitea-mcp.hiddenden.cafe/mcp`
- Authentication: OAuth
- OAuth client ID: the client id returned by `/register` or your preconfigured client id
- OAuth client secret: only for confidential clients
Hosted Claude callbacks are allowed by default: `https://claude.ai/api/mcp/auth_callback` and `https://claude.com/api/mcp/auth_callback`. Loopback redirects for Claude Code local development are allowed for `http://127.0.0.1:*` and `http://localhost:*`.
### 4) OAuth-protected MCP behavior
The server publishes protected-resource metadata:
- `GET /.well-known/oauth-protected-resource`
Example response:
```json
{
"resource": "https://gitea-mcp.hiddenden.cafe",
"authorization_servers": [
"https://gitea-mcp.hiddenden.cafe",
"https://git.hiddenden.cafe"
],
"bearer_methods_supported": ["header"],
"scopes_supported": ["read:repository", "write:repository"],
"resource_documentation": "https://hiddenden.cafe/docs/mcp-gitea"
}
```
If a tool call is missing/invalid auth, MCP endpoints return `401` with:
```http
WWW-Authenticate: Bearer resource_metadata="https://<mcp-host>/.well-known/oauth-protected-resource", scope="read:repository"
```
## Architecture
```text
Claude / Claude Code / Cowork
-> Authorization Code Flow
-> Gitea OAuth2/OIDC (issuer: https://git.hiddenden.cafe)
-> Access token
-> MCP Server (/mcp, /mcp/sse, /mcp/tool/call)
-> OIDC discovery + JWKS cache
-> Scope enforcement (read:repository / write:repository)
-> Policy allow/deny
-> If GITEA_TOKEN is set: check Gitea collaborator permission for <user, repo>
-> Gitea API call with either the user token or the service PAT after authz
```
## Example curl
Protected resource metadata:
```bash
curl -s https://<mcp-host>/.well-known/oauth-protected-resource | jq
```
Expected 401 challenge when missing token:
```bash
curl -i https://<mcp-host>/mcp/tool/call \
-H "Content-Type: application/json" \
-d '{"tool":"list_repositories","arguments":{}}'
```
Authenticated tool call:
```bash
curl -s https://<mcp-host>/mcp/tool/call \
-H "Authorization: Bearer <user_access_token>" \
-H "Content-Type: application/json" \
-d '{"tool":"get_repository_info","arguments":{"owner":"acme","repo":"demo"}}'
```
## Threat model
- Shared bot tokens are dangerous:
- one leaked token can expose all repositories reachable by that bot account.
- blast radius is repository-wide and cross-user.
- Token-in-URL is insecure:
- URLs leak via logs, proxies, browser history, and referers.
- bearer tokens must be sent in `Authorization` headers only.
- Per-user OAuth reduces lateral access:
- identity comes from Gitea OIDC/JWKS or userinfo validation.
- without `GITEA_TOKEN`, API calls use the user's token and Gitea enforces permissions.
- with `GITEA_TOKEN`, every repository-targeted call first checks the user's Gitea permission and fails closed if the check cannot be made.
## CI/CD
Gitea workflows were added under `.gitea/workflows/`:
- `lint.yml`: Ruff + formatting + mypy.
- `test.yml`: lint + pytest + enforced coverage (`>=80%`).
- `docker.yml`: lint+test gated Docker build, SHA tag, `latest` tag on `main`.
## Docker hardening
`docker/Dockerfile` uses a multi-stage build, non-root runtime user, production env flags, minimal runtime dependencies, and a healthcheck.
## Commands
- `make test`
- `make lint`
- `make format`
- `make docker-build`
- `make docker-up`
## Documentation
- `docs/api-reference.md`
- `docs/security.md`
- `docs/configuration.md`
- `docs/deployment.md`
- `docs/write-mode.md`