feat: initial mcp-chassis with auth primitives
Shared Go library for Mathias-owned MCP servers, born from spike S3 of the 2026-05 homelab architecture review (see gitea.d-ma.be/mathias/infra/docs/superpowers/handoffs/2026-05-22-mcp-chassis-spike.md for the viability assessment and abort-criterion check). Provides three primitives every MCP server today re-implements: - auth.JWTValidator — Dex OIDC JWT validation. nil-safe (nil = "JWT disabled"), audience-optional. Lifted from the identical ~80-LOC implementations in gitea-mcp and hyperguild/ingestion. - auth.BearerMiddleware — dual-mode static-Bearer-or-Dex-JWT gate. Static wins first to avoid emitting a WWW-Authenticate challenge that would flip claude.ai's MCP client into OAuth discovery for static-only deployments. The fall-through 401 emits the RFC 9728 resource_metadata header only when explicitly configured. - auth.ProtectedResourceHandler — RFC 9728 /.well-known/oauth-protected-resource metadata document handler. Test coverage exercises every branch (static OK, JWT-disabled, empty bearer, wrong static, with-challenge vs without-challenge, nil-validator-Validate). go test -race clean. Deps: github.com/lestrrat-go/jwx/v2 (already a dep of every consumer) and testify (test-only). No new transitive deps. First migration target: gitea-mcp. If that port lands in one PR (abort criterion from spec), brain-mcp (ingestion) follows. Otherwise chassis reverts per the spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 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/v2` plumbing)
|
||||
- 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/metrics`
|
||||
for the hand-rolled Prometheus pattern. May absorb a `metrics` subpackage here
|
||||
later, once a second consumer needs it.
|
||||
|
||||
## Packages
|
||||
|
||||
### `auth`
|
||||
|
||||
- `JWTValidator` — Dex OIDC JWT validation. `nil` is a valid value meaning
|
||||
"JWT auth disabled".
|
||||
- `BearerMiddleware` — static-Bearer-or-Dex-JWT gate. Static wins first; only
|
||||
emits `WWW-Authenticate: Bearer ... resource_metadata=...` on 401 when
|
||||
`resourceMetadataURL` is non-empty (claude.ai OAuth discovery).
|
||||
- `ProtectedResourceHandler` — RFC 9728 metadata document for
|
||||
`GET /.well-known/oauth-protected-resource`.
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.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 gitea.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.
|
||||
Reference in New Issue
Block a user