docs: add HOW_GITEA_MCP_WORKS — architecture, auth, tools, deploy
Explains the request lifecycle (Origin → Bearer → Caller → MCP), multi-issuer auth (static bearer / Authentik / k8s SA tokens per ADR-0011), the owner allowlist + caller-attribution footer, the single-service-PAT upstream client, the ~60 tools + input-hygiene layer, config, health, and the CI/CD + netpol-pilot deployment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <token>` 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 @<caller>_`, 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 <PAT>` (`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:<sha>`, 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}`.
|
||||
Reference in New Issue
Block a user