Files
gitea-mcp/docs/HOW_GITEA_MCP_WORKS.md
T
mathiasandClaude Sonnet 5.5 c3d8401604
CD / Lint / Test / Vet (pull_request) Successful in 14s
CD / Build & Import (pull_request) Skipped
CD / Deploy via GitOps (pull_request) Skipped
feat(pr_merge): gate it like tbd_ship — green CI, unprotected base, exactly one risk/low issue (gitea-mcp#74)
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
2026-10-08 08:11:40 +02:00

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):

  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, seds 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}.