Explains the request lifecycle (Origin → Bearer → Caller → MCP), multi-issuer auth (static bearer / Authentik / k8s SA tokens per ADR-0011), the owner allowlist + caller-attribution footer, the single-service-PAT upstream client, the ~60 tools + input-hygiene layer, config, health, and the CI/CD + netpol-pilot deployment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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):
- 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) |
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}.