Files
gitea-mcp/docs/HOW_GITEA_MCP_WORKS.md
T
mathiasandClaude Opus 4.8 1eceb5f7fe
CD / Lint / Test / Vet (push) Successful in 18s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 4s
ci(cd): skip docs-only pushes; move HOW_GITEA_MCP_WORKS into docs/
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>
2026-07-07 00:43:46 +02:00

9.6 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 PATGITEA_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 normalizationreponame and numberindex 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}.