Files
gitea-mcp/cmd/gitea-mcp/main.go
T
mathiasandClaude Opus 5 09bde6df8a
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 30s
CD / Build & Import (push) Successful in 15s
fix(health): /healthz answers now, and /readyz takes a degraded pod out
Closes #57. Consumes mcp-chassis v0.6.0 (mcp-chassis#4).

Two halves of one six-day outage. The chassis fix alone would not have fixed
this one.

/HEALTHZ WAS A CONSTANT

newHealthzHandler marshalled its body ONCE at startup and served those bytes for
the process lifetime. On 2026-09-14 it reported jwt.status=degraded for six days
after Authentik had recovered, because the answer was computed before Authentik
was up and could never change. A health endpoint that cannot change its answer
is not a health endpoint.

It now evaluates readiness per request. That is also what drives recovery: the
chassis resolves a pending issuer on demand, so every probe is a retry.

/READYZ IS NEW, AND IT IS THE ACTUAL CONTROL

Through all three occurrences the pod was 1/1 Running and readiness-green while
401ing every OAuth caller. k8s had no signal and kept routing to it. /readyz
answers 503 while JWT auth is degraded, so the pod leaves the Service instead of
serving guaranteed failures, and the kubelet's probe cadence becomes the retry
loop — no background goroutine, no context to own.

/healthz deliberately stays 200. The process is alive; a liveness probe must not
restart it, because restarting does not make the IdP come back sooner.

A server with no issuer configured is always ready. Making readiness depend on an
issuer it was never given would pull every static-token-only MCP out of its
Service — asserted, not assumed.

THE RETRY-WITHOUT-THE-K8S-ISSUER FALLBACK IS GONE

It existed because one unreachable issuer used to fail the whole constructor and
take Authentik auth down with it. Since v0.6.0 each issuer resolves lazily and
independently, so a down issuer degrades only itself. The warning it logged —
"multi-issuer init failed; retrying without the k8s issuer" — was the first line
of today's incident; it no longer has a reason to exist.

A constructor error now means a config error, which exits non-zero rather than
warning and continuing.

TESTS

healthz_live_test.go, written first and red (newReadyzHandler undefined, and
newHealthzHandler's signature could not express a state that changes). The
sharpest assertion is that readiness is evaluated TWICE for two requests — a
handler that caches cannot pass it.

The existing table test is ported, not weakened: same three cases, same expected
statuses, only the mechanism by which state reaches the handler changed.

Verified: task check EXIT=0 (lint, vet, race). The lint failure it surfaced was
pre-existing and is fixed in the preceding commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:06:15 +02:00

114 lines
4.3 KiB
Go

package main
import (
"context"
"log/slog"
"net/http"
"os"
"strings"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/config"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
)
// version is the build version, overridable via -ldflags "-X main.version=<tag>".
// Defaults to "dev" for local / un-stamped builds (was a hardcoded, drifting
// "0.1.0" — it now tells the truth instead of a stale literal).
var version = "dev"
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Route the chassis's package-level slog (auth audit logs, gitea-mcp#9) through
// the same structured handler as the rest of the server.
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
logger.Error("load config", "err", err)
os.Exit(1)
}
ctx := context.Background()
// Build the trusted-issuer list. Authentik (DEX_ISSUER_URL) for interactive/
// web clients; the in-cluster k8s OIDC issuer for ServiceAccount tokens
// (ADR-0011 D1). The k8s issuer is ADDITIVE and best-effort: if its client
// can't be built (not in a pod) or its discovery is unreachable at startup,
// it is dropped so existing Authentik/static auth is never taken down.
var issuers []chassisauth.IssuerConfig
if cfg.DexIssuerURL != "" {
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.DexIssuerURL, Audience: cfg.MCPAudience})
}
if cfg.K8sIssuerURL != "" {
if client, cerr := k8sOIDCClient(); cerr != nil {
logger.Warn("k8s OIDC client init failed; SA-token auth disabled (other auth unaffected)", "err", cerr)
} else {
issuers = append(issuers, chassisauth.IssuerConfig{IssuerURL: cfg.K8sIssuerURL, Audience: cfg.K8sAudience, HTTPClient: client})
}
}
// No retry-without-the-k8s-issuer dance any more, and no "JWT auth degraded"
// warning to act on. Since mcp-chassis v0.6.0 an unreachable issuer does not
// fail construction: each issuer resolves independently and lazily, so one
// down issuer degrades only itself and recovers on its own. The fallback this
// replaced existed precisely because a single unreachable issuer used to take
// the whole validator with it.
jwtValidator, err := chassisauth.NewMultiJWTValidator(ctx, issuers)
if err != nil {
// Only a configuration error can reach here now.
logger.Error("jwt validator config invalid", "err", err)
os.Exit(1)
}
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
ownerAllow := allowlist.New(cfg.AllowedOwners)
reg := registry.New()
tools.RegisterAll(reg, giteaClient, ownerAllow, cfg.GiteaBaseURL, "mathias", "template-go-web")
mcpSrv := mcp.NewServer(mcp.ServerOptions{
Registry: reg,
Sessions: mcp.NewSessionStore(),
})
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
// when both MCPResourceURL and a Dex issuer are wired; empty disables
// the challenge so static-only clients aren't pushed into OAuth discovery.
var resourceMetadataURL string
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
}
mux := http.NewServeMux()
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
auth.PassthroughMiddleware(giteaClient, mcpSrv,
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
auth.CallerMiddleware(logger, mcpSrv),
),
),
))
// Readiness is evaluated per request, and evaluating it is what retries a
// pending issuer — so the kubelet's probe cadence is the recovery loop.
dexConfigured := cfg.DexIssuerURL != ""
mux.Handle("/healthz", newHealthzHandler(dexConfigured, jwtValidator.Ready))
mux.Handle("/readyz", newReadyzHandler(dexConfigured, jwtValidator.Ready))
if cfg.DexIssuerURL != "" {
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
}
addr := ":" + cfg.Port
logger.Info("gitea-mcp starting", "addr", addr, "version", version)
if err := http.ListenAndServe(addr, mux); err != nil {
logger.Error("server stopped", "err", err)
os.Exit(1)
}
}