Metadata-Version: 2.4
Name: aegis-gitea-mcp
Version: 0.2.0
Summary: Security-first MCP server for controlled AI access to self-hosted Gitea (local stdio + public HTTP/OAuth)
Author: AegisGitea MCP Contributors
License: MIT
Project-URL: Homepage, https://git.hiddenden.cafe/Hiddenden/AegisGitea-MCP
Project-URL: Documentation, https://git.hiddenden.cafe/Hiddenden/AegisGitea-MCP/src/branch/main/README.md
Project-URL: Repository, https://git.hiddenden.cafe/Hiddenden/AegisGitea-MCP.git
Project-URL: Issues, https://git.hiddenden.cafe/Hiddenden/AegisGitea-MCP/issues
Keywords: mcp,gitea,ai,security,audit
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.26.0
Requires-Dist: pydantic>=2.5.0
Requires-Dist: pydantic-settings>=2.1.0
Requires-Dist: PyYAML>=6.0.1
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: structlog>=24.1.0
Requires-Dist: mcp>=1.2.0
Provides-Extra: server
Requires-Dist: fastapi>=0.109.0; extra == "server"
Requires-Dist: uvicorn[standard]>=0.27.0; extra == "server"
Requires-Dist: PyJWT[crypto]>=2.9.0; extra == "server"
Requires-Dist: python-multipart>=0.0.9; extra == "server"
Provides-Extra: dev
Requires-Dist: pytest>=7.4.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
Requires-Dist: pytest-httpx>=0.28.0; extra == "dev"
Requires-Dist: black>=24.1.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.8.0; extra == "dev"
Requires-Dist: pre-commit>=3.6.0; extra == "dev"
Dynamic: license-file

# AegisGitea-MCP

Security-first MCP server for self-hosted Gitea, available as **two transports built on one shared core**:

- **Local (stdio)** — `uvx aegis-gitea-mcp`. A single-user server for your own machine that authenticates with your Gitea Personal Access Token. No OAuth, no web stack. Ideal for Claude Desktop / Claude Code on your laptop.
- **Server (HTTP/OAuth)** — `aegis-gitea-mcp[server]` / Docker. The public, multi-user deployment with per-user OAuth2/OIDC, dynamic client registration, rate limiting, and per-user repository authorization. Exposes MCP over Streamable HTTP and a legacy SSE alias.

Both transports share the same tools, policy engine, secret sanitization, tamper-evident audit log, and — new in 0.2.0 — **safe full-API coverage** via the policy-gated `gitea_request` escape hatch plus **resource-type-aware authorization** for the admin/user/org surface.

> Branching / contribution flow: `HEAD -> feature branch -> dev -> main`. All pull requests target `dev`; `dev` is merged to `main` for releases. Never commit or push directly to `dev` or `main`.

## Run locally (stdio, single user)

Install nothing and run it with [`uv`](https://docs.astral.sh/uv/):

```bash
GITEA_URL=https://git.hiddenden.cafe \
GITEA_TOKEN=<your-gitea-personal-access-token> \
uvx aegis-gitea-mcp
```

Or install it:

```bash
pip install aegis-gitea-mcp        # core only (local stdio)
aegis-gitea-mcp                    # reads GITEA_URL + GITEA_TOKEN (or a .env file)
```

Wire it into Claude Code:

```bash
claude mcp add aegis-gitea -- uvx aegis-gitea-mcp
# with env values:
claude mcp add aegis-gitea -e GITEA_URL=https://git.hiddenden.cafe -e GITEA_TOKEN=<pat> -- uvx aegis-gitea-mcp
```

Or Claude Desktop (`claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "aegis-gitea": {
      "command": "uvx",
      "args": ["aegis-gitea-mcp"],
      "env": {
        "GITEA_URL": "https://git.hiddenden.cafe",
        "GITEA_TOKEN": "<your-gitea-personal-access-token>"
      }
    }
  }
}
```

The local server resolves your PAT's Gitea user at startup and pins every call to that identity. The policy engine and `WRITE_MODE` gate still apply (writes are off by default), and the audit log is written to a per-user path (e.g. `%LOCALAPPDATA%\aegis-gitea-mcp\audit.log` on Windows, `~/.local/state/aegis-gitea-mcp/audit.log` on Linux). See [docs/local-quickstart.md](docs/local-quickstart.md).

## Securing MCP with Gitea OAuth (public server)

> The HTTP/OAuth server needs the web stack: install with `pip install 'aegis-gitea-mcp[server]'` (or use Docker) and run `aegis-gitea-mcp-server`.

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`.
- `publish.yml`: on a `v*` tag, lint+test gated `uv build` + publish the Python package to the Gitea PyPI registry (see `docs/packaging.md`).

## 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/local-quickstart.md` — local stdio install and client wiring
- `docs/packaging.md` — build & publish with `uv`
- `docs/api-reference.md`
- `docs/security.md` — incl. resource-type-aware authorization
- `docs/configuration.md`
- `docs/deployment.md`
- `docs/write-mode.md`
- `docs/raw-api.md` — the `gitea_request` escape hatch
