diff --git a/HOW_GITEA_MCP_WORKS.md b/HOW_GITEA_MCP_WORKS.md new file mode 100644 index 0000000..1a95c2e --- /dev/null +++ b/HOW_GITEA_MCP_WORKS.md @@ -0,0 +1,200 @@ +# 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}`.