# 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= GITEA_OAUTH_CLIENT_SECRET= # 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= # 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 ``` ### 2b) Service PAT (`GITEA_TOKEN`) — needed in practice Gitea issues **OIDC access tokens** that carry only `openid/profile/email`. They establish identity but **cannot call the repository REST API**, so in pure-OAuth mode most tools fail (you will see a generic error, or `list_repositories` returning nothing usable). Configure a service PAT so the tools actually work: 1. Create a **dedicated bot account** in Gitea (not a personal account). 2. Generate a Personal Access Token with least privilege: - `read:repository` - `write:repository` only if you enable `WRITE_MODE` 3. Set it in `.env`: ```env GITEA_TOKEN= ``` This does **not** weaken per-user security. OAuth remains authoritative: before every repository call the server verifies that the signed-in user has permission on the target repo through Gitea (`_verify_user_repository_access`) and denies it otherwise. The PAT only performs the API call after that check; OAuth provides identity, per-user authorization, and audit attribution. Note: with a service PAT, `list_repositories` is **scoped to the signed-in user** — it returns only the repositories that user owns or contributes to (resolved via Gitea's repo search with the `uid` filter), not everything the bot can see. Visibility of private repos still depends on what the service token itself can access. All other tools require an explicit `owner`/`repo` and run the per-user permission check first. ### 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:///.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 -> Gitea API call with either the user token or the service PAT after authz ``` ## Example curl Protected resource metadata: ```bash curl -s https:///.well-known/oauth-protected-resource | jq ``` Expected 401 challenge when missing token: ```bash curl -i https:///mcp/tool/call \ -H "Content-Type: application/json" \ -d '{"tool":"list_repositories","arguments":{}}' ``` Authenticated tool call: ```bash curl -s https:///mcp/tool/call \ -H "Authorization: Bearer " \ -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`