Relocate the doc under docs/ and add paths-ignore: ['docs/**'] to the CD push trigger so documentation-only changes no longer rebuild the image and roll the pod. Branch pushes with only docs/ changes are skipped; tag (v*) pushes and any push touching code still deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
9.6 KiB
Markdown
201 lines
9.6 KiB
Markdown
# 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}`.
|