JWTValidator trusted a single issuer (iss matched exactly against DEX_ISSUER_URL),
which is why every in-cluster caller fell back to a static bearer. Add support
for a LIST of trusted issuers so one MCP server can accept Authentik JWTs
(interactive/web) AND k3s cluster ServiceAccount tokens (in-cluster, audience-
bound, kubelet-rotated) at once — the enabler for ADR-0011.
- Add IssuerConfig{IssuerURL, Audience} + NewMultiJWTValidator([]IssuerConfig).
- Refactor JWTValidator to hold []issuerEntry + a shared jwk.Cache; Validate
tries each trusted issuer, returns the subject on first success. All-issuers-
JWKS-unreachable -> ErrUnavailable (503); any definitive reject -> 401.
- NewJWTValidator (single issuer) and BearerMiddleware signatures UNCHANGED —
existing consumers (gitea-mcp, ingestion) compile and behave identically.
- Add auth/jwt_test.go: an in-process OIDC issuer harness (discovery + JWKS +
token minting) — the chassis previously had NO happy-path JWT test. Covers
accept-either-trusted-issuer, reject-untrusted (401 not 503), per-issuer
audience enforcement, single-issuer backward compat.
Proven viable by the ADR-0011 live-cluster spike (k3s OIDC/JWKS validates a
projected SA token). Follow-up: wire gitea-mcp/brain-mcp/ingestion to also trust
the k3s issuer + mount an audience-scoped projected SA token on one pod.
Refs infra ADR-0011; supersedes the sidecar in infra#183. (Tracked as
hyperguild#77, which was misfiled — the chassis is this repo, not hyperguild.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mcp-chassis
Shared Go library for Mathias-owned MCP servers. Provides the auth + middleware primitives that every MCP server needs.
Why
By 2026-05-22 there were three+ MCP servers (gitea-mcp, brain-mcp /
ingestion, future ones from template-go-agent) each carrying their
own near-identical:
- Dex JWT validator (~80 LOC, identical
jwx/v2plumbing) - Bearer middleware (~50 LOC, dual-mode static + JWT)
- RFC 9728 protected-resource metadata handler (~25 LOC)
The homelab architecture review's spike S3 (see
gitea.d-ma.be/mathias/infra/docs/superpowers/handoffs/2026-05-22-mcp-chassis-spike.md)
concluded a thin shared lib pays for itself within the first migration.
This is that lib.
Non-goals
- Replacing each MCP's tool registration / handler logic — that is per-domain.
- Solving HTTP routing — consumers keep their own
http.ServeMux. - Solving observability — see
gitea.d-ma.be/mathias/hyperguild/ingestion/internal/metricsfor the hand-rolled Prometheus pattern. May absorb ametricssubpackage here later, once a second consumer needs it.
Packages
auth
JWTValidator— Dex OIDC JWT validation.nilis a valid value meaning "JWT auth disabled".BearerMiddleware— static-Bearer-or-Dex-JWT gate. Static wins first; only emitsWWW-Authenticate: Bearer ... resource_metadata=...on 401 whenresourceMetadataURLis non-empty (claude.ai OAuth discovery).ProtectedResourceHandler— RFC 9728 metadata document forGET /.well-known/oauth-protected-resource.
Usage
package main
import (
"context"
"net/http"
"os"
"git.d-ma.be/mathias/mcp-chassis/auth"
)
func main() {
staticToken := os.Getenv("BRAIN_MCP_TOKEN")
dexIssuer := os.Getenv("DEX_ISSUER_URL")
audience := os.Getenv("MCP_AUDIENCE")
resourceURL := os.Getenv("MCP_RESOURCE_URL")
validator, err := auth.NewJWTValidator(context.Background(), dexIssuer, audience)
if err != nil {
panic(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
auth.ProtectedResourceHandler(resourceURL, dexIssuer))
mux.Handle("/mcp", auth.BearerMiddleware(
staticToken,
validator,
"brain",
resourceURL+"/.well-known/oauth-protected-resource",
mcpHandler(),
))
_ = http.ListenAndServe(":3300", mux)
}
func mcpHandler() http.Handler { /* per-domain */ return nil }
Versioning
Trunk-based development on main. Tagged with semver. Consumers pin
specific tags (go.mod require git.d-ma.be/mathias/mcp-chassis v0.x.y)
and bump deliberately.
Migrations are documented per-consumer in the consumer's CHANGELOG / commits.
Dependencies
github.com/lestrrat-go/jwx/v2— JWKS cache + JWT parsing. Same dep every MCP already had; no new transitive cost when adopting the chassis.github.com/stretchr/testify— tests only.
stdlib otherwise.