e873d0325b
Previously list_repositories was blocked in service-PAT mode because it has no repository target for the per-user permission check, so users could not list their repositories at all (the connector surfaced a generic error). list_repositories now returns only the repositories the signed-in user owns or contributes to, instead of everything the bot token can see: - gitea_client.py: add list_user_repositories(login) — resolves the user id and queries /api/v1/repos/search with the uid filter. - repository.py: list_repositories_tool uses the user-scoped path when a service PAT is configured and a user login is present; pure-OAuth mode still uses the user's own /user/repos. - server.py: allow list_repositories through the service-PAT guard (it is scoped to the user in the handler); all other tools still require a repository target. - README.md: document the new user-scoped behavior and its visibility caveat. Tests: user-scoped client method (uid resolution + unknown user), PAT-mode tool scoping, and conftest now clears the request context between tests to prevent contextvar login leakage across files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
225 lines
8.8 KiB
Markdown
225 lines
8.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
|
|
```
|
|
|
|
### 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=<bot-personal-access-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://<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`
|