pr_merge was an independent merge path with no CI, protection or tier check. It now reads the PR, the base's branch protection and the head commit's CI runs, takes the tier from the issue named in `issue`, and runs the same evaluateShipGate as tbd_ship (helpers shared, not copied). A refusal is a result (merged:false, reason, ci_status, risk); an unreadable PR or protection probe is an error; a merge conflict is still an error. Above risk/low a human merges in the UI. Tests: tier table, no-issue, issue lookup failures, CI and protection not overridden by risk/low, read-side failures, tier read from the named issue only; nine wiring mutants flip-tested red. Shared reason text now says "PR left open" (the PR already exists for pr_merge). Behaviour change: pr_merge without `issue`, or on a PR with no green CI, merges nothing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
215 lines
11 KiB
Markdown
215 lines
11 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) |
|
|
|
|
**Merge gate: `tbd_ship` (gitea-mcp#63) and `pr_merge` (gitea-mcp#74).** The two merge tools share one gate
|
|
(`evaluateShipGate`). A PR is merged only when ALL of: the head commit's CI is fully green, the base branch is not
|
|
review-protected, and the issue named in `issue` carries **exactly one** `risk/` label, `risk/low`. No `issue`, no
|
|
`risk/` label, several, an unknown value (`risk/lowish`), `risk/medium`, `risk/high`, or an issue that cannot be read
|
|
all fail closed: nothing is merged, the result is `merged:false` with a `reason`, the `ci_status` and the `risk` tier.
|
|
Unclassified is not low. Above `risk/low` a human reviews and merges in the Gitea UI (which this gate does not touch).
|
|
`pr_merge` additionally fails with an error, merging nothing, when the PR or the base's branch-protection state cannot
|
|
be read. A merge conflict is still an error.
|
|
|
|
Limits: the gate enforces the tier of the issue the caller names; it cannot prove that issue is the one the change
|
|
belongs to, so the link is the caller's claim (local-dev rule 5's convention still applies). Not gated: merging in the
|
|
Gitea UI, and `file_write_branch` committing directly to a branch (`main` included), which is how `risk/low` work
|
|
reaches trunk. Platform-level enforcement for those is branch protection on `main` (local-dev#27 option 2).
|
|
|
|
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}`.
|