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
11 KiB
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):
- Static bearer — constant-time compare against
GITEA_MCP_STATIC_TOKEN. Wins immediately, never emits aWWW-Authenticatechallenge. This is the service-to-service path (repo.mcp.jsonfiles carryingGITEA_MCP_TOKEN). - 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 (seecmd/gitea-mcp/main.goandcmd/gitea-mcp/k8soidc.go).
- Authentik (
- Otherwise 401. If
MCP_RESOURCE_URL+DEX_ISSUER_URLare set, the 401 carries aWWW-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 inGITEA_MCP_ALLOWED_OWNERS(defaultmathias). 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 overX-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↔nameandnumber↔indexare reconciled so a tool works whichever spelling a caller sends (an explicit canonical wins). - Required-identifier validation — an empty
repo/nameis rejected at the tool layer (avoids the bare-404 leak);owneris 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-chassisdeny) 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-mcpk3s namespace (git.d-ma.be/mathias/infrak3s/apps/gitea-mcp/): a Deployment onlocalhost:5000/gitea-mcp:<sha>, an ExternalSecret for tokens, and a default-deny NetworkPolicy + anallow-ingress-nginxrule (this namespace is the P6.1 netpol pilot — the only ingress allowed is fromingress-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) →buildahimage →localhost:5000→ smoke test → the deploy job clones infra over SSH,seds the image tag ink3s/apps/gitea-mcp/deployment.yaml, commits, and triggers a Flux reconcile. Push tomain⇒ 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-mcpNetworkPolicies 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(authpackage) — sharedBearerMiddleware+ multi-issuerJWTValidator+ 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}.