# How gitea-mcp works `gitea-mcp` is a custom **MCP (Model Context Protocol) front door for Gitea** — a small Go HTTP service that exposes ~60 Gitea operations as MCP tools over Streamable HTTP, so a claude.ai connector (or any MCP client) can drive the homelab's Gitea (`https://git.d-ma.be`) with proper auth, an owner allowlist, caller attribution, and defensive input handling. It is **not** a generic Gitea proxy. It is an opinionated, allowlisted, single- tenant front door built on the shared **`mcp-chassis`** auth library. --- ## 1. Request lifecycle Everything is wired in `cmd/gitea-mcp/main.go` on a plain `http.ServeMux`: ``` POST /mcp → OriginAllowlist (internal/mcp/origin.go) browser Origin gate → BearerMiddleware (mcp-chassis auth) authN: static token OR JWT → CallerMiddleware (internal/auth/caller.go) extract caller identity → MCP server (internal/mcp/server.go) JSON-RPC dispatch → tools GET /healthz unauthenticated health JSON GET /.well-known/oauth-protected-resource RFC 9728 metadata (when Dex set) ``` The middleware order is deliberate: Origin first (cheap reject), then authN, then identity extraction, then the MCP handler. A tool call is a JSON-RPC `tools/call` that the registry dispatches to a `Tool` handler. --- ## 2. Authentication (`mcp-chassis` `BearerMiddleware`) `Authorization: Bearer ` is checked with this precedence (`auth/bearer.go` in `mcp-chassis`): 1. **Static bearer** — constant-time compare against `GITEA_MCP_STATIC_TOKEN`. Wins immediately, never emits a `WWW-Authenticate` challenge. This is the service-to-service path (repo `.mcp.json` files carrying `GITEA_MCP_TOKEN`). 2. **JWT validation** — against a **list of trusted OIDC issuers** (`NewMultiJWTValidator`, ADR-0011): - **Authentik** (`DEX_ISSUER_URL` + `MCP_AUDIENCE=claude-ai`) — the claude.ai web connector after its OAuth handshake. - **k3s cluster OIDC** (`K8S_ISSUER_URL` + `K8S_MCP_AUDIENCE=gitea-mcp`) — lets in-cluster pods authenticate with audience-bound projected **ServiceAccount tokens** instead of a static bearer. The k8s issuer is additive and best-effort: if the in-cluster OIDC can't be reached at startup it is dropped so Authentik auth is never taken down (see `cmd/gitea-mcp/main.go` and `cmd/gitea-mcp/k8soidc.go`). 3. Otherwise **401**. If `MCP_RESOURCE_URL` + `DEX_ISSUER_URL` are set, the 401 carries a `WWW-Authenticate: … resource_metadata=…` header (RFC 9728) so claude.ai's OAuth discovery can find `/.well-known/oauth-protected-resource`. A JWKS/issuer outage yields **503** (`ErrUnavailable`), distinct from a present-but-invalid token's **401**, so a transient IdP blip is retried rather than treated as a hard auth failure. ### The k8s SA-token wrinkle (ADR-0011) k3s serves its OIDC discovery/JWKS over the **cluster CA** and requires an **authenticated** request (anonymous → 401). So `k8sOIDCClient()` builds an HTTP client that trusts `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` and carries the pod's own SA bearer, passed to the chassis via `IssuerConfig.HTTPClient`. Proven live: a real `aud=gitea-mcp` SA token → the running server → HTTP 200. ### Origin allowlist `OriginAllowlist` (`internal/mcp/origin.go`) rejects any request whose `Origin` header is not in `GITEA_MCP_ORIGIN_ALLOWLIST` (`https://claude.ai`, `https://api.anthropic.com`). An **empty** Origin (server-side callers) is allowed — Origin is a browser-only header. --- ## 3. Authorization, identity & attribution - **Owner allowlist** (`internal/allowlist`) — tools only operate on owners in `GITEA_MCP_ALLOWED_OWNERS` (default `mathias`). A call for any other owner is rejected before it reaches Gitea. - **Caller identity** (`internal/auth/caller.go`) — the authenticated username is read from reverse-proxy identity headers, `X-Auth-Request-User` (the verified OIDC identity, authoritative) preferred over `X-Forwarded-User`. Conflicts are logged, not silently resolved. - **Identity footer** (`internal/identity/footer.go`) — mutating tools that write a body (issue/PR comments, creates) append `_Created via git-mcp on behalf of @_`, so actions taken through the front door are attributable even though all upstream calls use one service PAT. --- ## 4. Upstream Gitea access `internal/gitea/Client` is a thin REST client over `GITEA_BASE_URL` (`https://git.d-ma.be`). Every upstream call carries a **single service PAT** — `GITEA_MCP_DEFAULT_TOKEN` — as `Authorization: token ` (`client.go`). So gitea-mcp is a *front door*, not a credential pass-through: the caller's identity is captured for attribution, but Gitea sees one service account. A 60s branch cache and a 30s HTTP timeout round it out. Defensive guard: paths with an empty owner/repo segment (`//`) are rejected locally (`hasEmptySegment`) so callers get a typed validation error instead of Gitea's opaque `/api/swagger` 404. --- ## 5. Tools Tools are registered in `internal/tools/registry.go` into a `registry.Registry`; each implements the `Tool` interface (name, JSON schema, handler). ~60 tools, by area: | Area | Tools | |------|-------| | Repos | list, get, search, status, create, delete, update, tree, topics_update, mirror_push | | Files | file_read, file_write_branch, file_delete, dir_list | | Branches | branch_list, branch_delete, branch_protection_get | | Issues | list, get, create, edit, close, reopen, comment, label, list_comments | | PRs | list, get, create, comment, merge, files_diff | | Labels / Releases / Tags | label_list, release_create, tag_create | | Workflows (Actions) | run_list, run_status, run_trigger | | Search / Templates | code_search, create_project_from_template | | Composite | tbd_ship (trunk-based ship helper) | Shared tool-layer hygiene (`internal/tools/tool.go`): - **Alias normalization** — `repo`↔`name` and `number`↔`index` are reconciled so a tool works whichever spelling a caller sends (an explicit canonical wins). - **Required-identifier validation** — an empty `repo`/`name` is rejected at the tool layer (avoids the bare-404 leak); `owner` is covered by the allowlist. - **Page-size cap** — limits are clamped to 50. --- ## 6. Configuration (env) | Var | Purpose | Example | |-----|---------|---------| | `GITEA_MCP_PORT` | listen port | `8080` | | `GITEA_BASE_URL` | upstream Gitea | `https://git.d-ma.be` | | `GITEA_MCP_DEFAULT_TOKEN` | **upstream** service PAT (all Gitea calls) | *(secret)* | | `GITEA_MCP_STATIC_TOKEN` | static bearer for service-to-service auth | *(secret)* | | `GITEA_MCP_ALLOWED_OWNERS` | owner allowlist | `mathias` | | `GITEA_MCP_ORIGIN_ALLOWLIST` | permitted browser Origins | `https://claude.ai,…` | | `DEX_ISSUER_URL` / `MCP_AUDIENCE` | Authentik issuer + audience | `…/o/claude-ai/`, `claude-ai` | | `K8S_ISSUER_URL` / `K8S_MCP_AUDIENCE` | k8s OIDC issuer + audience (SA tokens) | `https://kubernetes.default.svc.cluster.local`, `gitea-mcp` | | `MCP_RESOURCE_URL` | this server's public URL for `.well-known` metadata | `https://git-mcp.d-ma.be` | Secrets come from k8s Secrets (`gitea-mcp-secrets`, `gitea-mcp-static-token`); non-secret values are inlined in the Deployment. --- ## 7. Health & observability - `GET /healthz` → `{"ok":true,"jwt":{"status":"disabled|enabled|degraded","last_error":"…"}}`. `degraded` = a JWT issuer was configured but init failed (static-token auth still works) — distinguishes "IdP unreachable" from "JWT not configured". - Every auth rejection is audit-logged (`mcp-chassis` `deny`) with the reason, client IP, token *type*, and a truncated SHA-256 **fingerprint** — never the raw token, so nothing secret lands in logs. --- ## 8. Deployment & CI/CD - **Runs in** the `gitea-mcp` k3s namespace (`git.d-ma.be/mathias/infra` `k3s/apps/gitea-mcp/`): a Deployment on `localhost:5000/gitea-mcp:`, an ExternalSecret for tokens, and a **default-deny NetworkPolicy** + an `allow-ingress-nginx` rule (this namespace is the P6.1 netpol pilot — the only ingress allowed is from `ingress-nginx`). - **Public endpoint** `https://git-mcp.d-ma.be` (NPM → ingress-nginx → svc:8080). - **Pipeline** (`.gitea/workflows/cd.yml`, self-hosted koala runner): quality gate (test/vet) → `buildah` image → `localhost:5000` → smoke test → the deploy job clones infra over SSH, `sed`s the image tag in `k3s/apps/gitea-mcp/deployment.yaml`, commits, and triggers a **Flux** reconcile. Push to `main` ⇒ new image ⇒ rolled. --- ## 9. Operational notes - **Rollback** = revert the image tag (or the env change) in the infra Deployment; Flux rolls back. Auth changes are additive, so enabling/disabling the k8s issuer never affects the Authentik/static paths. - **NetworkPolicy kill switch** — delete the `gitea-mcp` NetworkPolicies to restore open ingress if the pilot ever locks something out. - **claude.ai connector flakiness** — the claude.ai→gitea-mcp MCP path intermittently hits a Cloudflare "you have been blocked" page (the error names `anthropic.com`). On the Claude Code CLI, prefer the direct Gitea REST API for reliability; the MCP connector remains the claude.ai-web path. (brain: `prefer-rest-api-skills-over-mcp-on-cli-cloudflare-waf`.) ## References - Auth library: `git.d-ma.be/mathias/mcp-chassis` (`auth` package) — shared `BearerMiddleware` + multi-issuer `JWTValidator` + RFC 9728 handler. - Multi-issuer / SA-token design: infra `docs/decisions/ADR-0011-service-to-service-auth.md`. - Source map: `cmd/gitea-mcp/{main,healthz,k8soidc}.go`, `internal/{mcp,auth,gitea,tools,allowlist,identity,registry,config}`.