Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e9a2e30e | ||
|
|
09bde6df8a | ||
|
|
aaaa4b4d64 | ||
|
|
2bb1fef543 | ||
|
|
ee9ac99c40 |
+9
-4
@@ -2,21 +2,26 @@
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"brain": {
|
"brain": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://brain-mcp.d-ma.be/mcp",
|
"url": "https://koala.tail30472e.ts.net:8443/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gitea": {
|
"gitea": {
|
||||||
|
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT it validates against Gitea and then acts as. GITEA_MCP_TOKEN -- which this entry used until 2026-09-08 -- 401s against it, so every harness synced from this file had a dead gitea server and no signal saying so. Probed 2026-09-08: GITEA_MCP_TOKEN 401, DMABE_GITEA_API_TOKEN 200, on both the public and the Serve URL.",
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://git-mcp.d-ma.be/mcp",
|
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
|
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"infra": {
|
"infra": {
|
||||||
|
"_comment": "The bearer is belt-and-braces: this endpoint answers 200 with or without it, since Serve already limits reach to the tailnet. Sending it anyway means the day the server starts enforcing is not the day every harness breaks.",
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://infra-mcp.d-ma.be/mcp"
|
"url": "https://koala.tail30472e.ts.net:8444/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Canonical golangci-lint configuration for the estate's Go repos (infra#309
|
||||||
|
# pattern: one canonical file here, a byte-identical copy in each adopter,
|
||||||
|
# and the drift gate in THIS repo comparing sha256).
|
||||||
|
#
|
||||||
|
# Why this exists: 26 Go repos, 3 had a lint config, and all three were
|
||||||
|
# different. The 23 without one ran golangci-lint's v2 default set, which
|
||||||
|
# does NOT include a formatter -- so gofmt/gofumpt drift went uncaught until
|
||||||
|
# it was found by hand (dma-cli#12: two files drifted, nothing flagged them).
|
||||||
|
#
|
||||||
|
# The two configs this replaces (cobalt-dingo, coo-agent) were near-identical
|
||||||
|
# copies of each other AND both invalid under v2: they carried v1-style
|
||||||
|
# `linters-settings` and `issues.exclude-rules` keys, which `golangci-lint
|
||||||
|
# config verify` rejects outright. Their revive settings and test exclusions
|
||||||
|
# were therefore not in effect. Consolidating fixes that too.
|
||||||
|
#
|
||||||
|
# Per-repo variation does NOT belong here -- this file must stay byte-identical
|
||||||
|
# across adopters so the gate is a checksum, not a diff a human has to judge.
|
||||||
|
# A repo with a genuine exception (agentsquad excludes its seeded-bug fixture
|
||||||
|
# tree, which exists precisely to contain broken code) keeps its own config and
|
||||||
|
# is not listed as an adopter.
|
||||||
|
|
||||||
|
version: "2"
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- errcheck # unchecked errors
|
||||||
|
- govet # suspicious constructs
|
||||||
|
- staticcheck # broad static analysis
|
||||||
|
- unused # unused code
|
||||||
|
- misspell # spelling
|
||||||
|
- revive # idiomatic Go
|
||||||
|
- exhaustive # exhaustive switch on enums
|
||||||
|
|
||||||
|
settings:
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: var-naming
|
||||||
|
severity: warning
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
# Test files: table-driven cases legitimately carry unused struct fields
|
||||||
|
# and long functions.
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- revive
|
||||||
|
- unused
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofumpt
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -22,15 +23,67 @@ type jwtStatus struct {
|
|||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHealthzHandler(dexConfigured, validatorReady bool, initErr error) http.HandlerFunc {
|
// readinessFn reports whether JWT validation is currently possible, and why
|
||||||
status := healthStatus{OK: true, JWT: jwtStatus{Status: jwtStatusFor(dexConfigured, validatorReady)}}
|
// not. chassisauth.JWTValidator.Ready satisfies it.
|
||||||
if initErr != nil {
|
type readinessFn func(context.Context) (bool, error)
|
||||||
status.JWT.LastError = initErr.Error()
|
|
||||||
}
|
// newHealthzHandler reports the CURRENT state, evaluated per request.
|
||||||
body, _ := json.Marshal(status)
|
//
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
// It used to marshal one body at startup and serve those bytes forever. That is
|
||||||
|
// how gitea-mcp reported jwt.status=degraded for six days after Authentik had
|
||||||
|
// recovered (mcp-chassis#4): the answer was computed before the IdP was up and
|
||||||
|
// could never change. Fixing the validator alone would not have fixed this —
|
||||||
|
// the validator would recover and /healthz would keep saying degraded.
|
||||||
|
//
|
||||||
|
// Calling ready() per request is also what drives recovery: the chassis resolves
|
||||||
|
// a pending issuer on demand, so every probe is a retry.
|
||||||
|
func newHealthzHandler(dexConfigured bool, ready readinessFn) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := healthStatus{OK: true, JWT: jwtStatus{Status: "disabled"}}
|
||||||
|
if dexConfigured {
|
||||||
|
ok, err := ready(r.Context())
|
||||||
|
status.JWT.Status = jwtStatusFor(true, ok)
|
||||||
|
if err != nil {
|
||||||
|
status.JWT.LastError = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write(body)
|
_ = json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newReadyzHandler is the kubelet's probe: 503 while JWT auth is degraded.
|
||||||
|
//
|
||||||
|
// /healthz stays 200 deliberately — the process is alive and a liveness probe
|
||||||
|
// must not restart it, because restarting does not make the IdP come back any
|
||||||
|
// sooner. Readiness is the right lever: k8s stops routing OAuth callers to a pod
|
||||||
|
// that would 401 them, and the probe's own cadence becomes the retry loop with
|
||||||
|
// no background goroutine to own.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
func newReadyzHandler(dexConfigured bool, ready readinessFn) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !dexConfigured {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok, err := ready(r.Context())
|
||||||
|
if !ok {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
msg := ""
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(healthStatus{
|
||||||
|
OK: false,
|
||||||
|
JWT: jwtStatus{Status: "degraded", LastError: msg},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readinessFunc is what the handler asks. Named here so the test can drive it.
|
||||||
|
type fakeReadiness struct{ calls int }
|
||||||
|
|
||||||
|
// ready reports not-ready on the first call and ready afterwards — the shape of
|
||||||
|
// an IdP that was down when the process booted and came back a minute later.
|
||||||
|
func (f *fakeReadiness) ready(context.Context) (bool, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.calls == 1 {
|
||||||
|
return false, errBoot
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errBoot = &bootErr{}
|
||||||
|
|
||||||
|
type bootErr struct{}
|
||||||
|
|
||||||
|
func (*bootErr) Error() string { return "oidc discovery: status 503" }
|
||||||
|
|
||||||
|
// A health endpoint that answers from a value captured at startup is not a
|
||||||
|
// health endpoint.
|
||||||
|
//
|
||||||
|
// newHealthzHandler marshalled its body ONCE and served the same bytes for the
|
||||||
|
// process lifetime. On 2026-09-14 gitea-mcp reported jwt.status=degraded for six
|
||||||
|
// days after Authentik recovered, because the answer had been computed before
|
||||||
|
// Authentik was up and could never change. The pod was 1/1 Running and
|
||||||
|
// readiness-green throughout.
|
||||||
|
//
|
||||||
|
// Fixing mcp-chassis#4 alone does not fix this: the validator would recover and
|
||||||
|
// /healthz would keep saying degraded.
|
||||||
|
func TestHealthz_ReflectsCurrentState_NotBootState(t *testing.T) {
|
||||||
|
f := &fakeReadiness{}
|
||||||
|
h := newHealthzHandler(true, f.ready)
|
||||||
|
|
||||||
|
first := decodeHealth(t, h)
|
||||||
|
require.Equal(t, "degraded", first.JWT.Status, "first call: issuer still down")
|
||||||
|
require.NotEmpty(t, first.JWT.LastError, "degraded must say why")
|
||||||
|
|
||||||
|
second := decodeHealth(t, h)
|
||||||
|
require.Equal(t, "enabled", second.JWT.Status,
|
||||||
|
"the issuer recovered; /healthz must notice without a restart")
|
||||||
|
require.Empty(t, second.JWT.LastError)
|
||||||
|
|
||||||
|
require.Equal(t, 2, f.calls, "readiness must be evaluated per request, not cached at startup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no issuer configured, JWT auth is disabled by choice and readiness is
|
||||||
|
// never consulted — a static-token-only server must not be reported degraded.
|
||||||
|
func TestHealthz_NoIssuer_IsDisabledNotDegraded(t *testing.T) {
|
||||||
|
f := &fakeReadiness{}
|
||||||
|
h := newHealthzHandler(false, f.ready)
|
||||||
|
|
||||||
|
got := decodeHealth(t, h)
|
||||||
|
require.Equal(t, "disabled", got.JWT.Status)
|
||||||
|
require.Zero(t, f.calls, "no issuer configured means nothing to probe")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readyz is what the kubelet probes. It must FAIL while JWT auth is degraded, so
|
||||||
|
// k8s stops routing to a pod that will 401 every OAuth caller — and so the
|
||||||
|
// probe's own cadence becomes the retry loop.
|
||||||
|
func TestReadyz_FailsWhileDegraded_ThenPasses(t *testing.T) {
|
||||||
|
f := &fakeReadiness{}
|
||||||
|
h := newReadyzHandler(true, f.ready)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
require.Equal(t, http.StatusServiceUnavailable, rec.Code,
|
||||||
|
"a pod that cannot validate any JWT must not be in the Service")
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "recovered without a restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A static-token-only server is always ready. Making readyz depend on an issuer
|
||||||
|
// it was never given would take every static-only MCP out of its Service.
|
||||||
|
func TestReadyz_NoIssuer_AlwaysReady(t *testing.T) {
|
||||||
|
f := &fakeReadiness{}
|
||||||
|
h := newReadyzHandler(false, f.ready)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
require.Zero(t, f.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeHealth(t *testing.T, h http.HandlerFunc) healthStatus {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
var got healthStatus
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||||
|
return got
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -43,7 +44,11 @@ func TestHealthzHandler(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr)
|
// Readiness is now a function the handler calls per request rather
|
||||||
|
// than two values captured at startup (mcp-chassis#4). The cases
|
||||||
|
// below are unchanged; only how the state reaches the handler is.
|
||||||
|
ready := func(context.Context) (bool, error) { return tc.validatorReady, tc.initErr }
|
||||||
|
h := newHealthzHandler(tc.dexConfigured, ready)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -54,21 +54,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jwtValidator, jwtInitErr := chassisauth.NewMultiJWTValidator(ctx, issuers)
|
// No retry-without-the-k8s-issuer dance any more, and no "JWT auth degraded"
|
||||||
if jwtInitErr != nil && cfg.K8sIssuerURL != "" && len(issuers) > 1 {
|
// warning to act on. Since mcp-chassis v0.6.0 an unreachable issuer does not
|
||||||
// A configured issuer (likely the in-cluster k8s OIDC) was unreachable at
|
// fail construction: each issuer resolves independently and lazily, so one
|
||||||
// startup. Don't let it take down Authentik JWT auth — retry without it.
|
// down issuer degrades only itself and recovers on its own. The fallback this
|
||||||
logger.Warn("multi-issuer init failed; retrying without the k8s issuer", "err", jwtInitErr)
|
// replaced existed precisely because a single unreachable issuer used to take
|
||||||
pub := make([]chassisauth.IssuerConfig, 0, len(issuers))
|
// the whole validator with it.
|
||||||
for _, ic := range issuers {
|
jwtValidator, err := chassisauth.NewMultiJWTValidator(ctx, issuers)
|
||||||
if ic.IssuerURL != cfg.K8sIssuerURL {
|
if err != nil {
|
||||||
pub = append(pub, ic)
|
// Only a configuration error can reach here now.
|
||||||
}
|
logger.Error("jwt validator config invalid", "err", err)
|
||||||
}
|
os.Exit(1)
|
||||||
jwtValidator, jwtInitErr = chassisauth.NewMultiJWTValidator(ctx, pub)
|
|
||||||
}
|
|
||||||
if jwtInitErr != nil {
|
|
||||||
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||||
@@ -98,7 +94,11 @@ func main() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
|
// 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 != "" {
|
if cfg.DexIssuerURL != "" {
|
||||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module git.d-ma.be/mathias/gitea-mcp
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.d-ma.be/mathias/mcp-chassis v0.5.0
|
git.d-ma.be/mathias/mcp-chassis v0.7.1
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8=
|
git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8=
|
||||||
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.6.0 h1:/MuxT9IyzZbN26+COlI2FK0moTyXMLG/qXc/cm0i0Vg=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.6.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.7.0 h1:ct31v1F1PMhMCva/V0ErEbgMzwnT+27lYKbug2A/quE=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.7.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.7.1 h1:4h8TOBHTA0L/no4IPcr4tLcQtmKUlhE2/cXyPdNFGmM=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.7.1/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,11 +28,34 @@ func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Hand
|
|||||||
token, hasBearer := strings.CutPrefix(authz, "Bearer ")
|
token, hasBearer := strings.CutPrefix(authz, "Bearer ")
|
||||||
if hasBearer && token != "" {
|
if hasBearer && token != "" {
|
||||||
if login, ok := validator.ValidateToken(r.Context(), token); ok {
|
if login, ok := validator.ValidateToken(r.Context(), token); ok {
|
||||||
|
// gitea-mcp#60. This middleware logged nothing at all, so there
|
||||||
|
// was no way to tell a PAT pass-through request from one that
|
||||||
|
// fell through to the shared JWT/static path — only inference
|
||||||
|
// from which failures were absent.
|
||||||
|
//
|
||||||
|
// It matters more here than on the other two paths: this is
|
||||||
|
// where a request acts as a SPECIFIC Gitea user. An audit trail
|
||||||
|
// that cannot say which identity performed a write is not an
|
||||||
|
// audit trail.
|
||||||
|
//
|
||||||
|
// chassisauth.Fingerprint, not a local equivalent: the chassis
|
||||||
|
// emits the same field on its own accepted/rejected lines, and
|
||||||
|
// two different fingerprints for one token cannot be correlated.
|
||||||
|
slog.Info("mcp auth accepted",
|
||||||
|
"auth_path", "passthrough",
|
||||||
|
"caller", login,
|
||||||
|
"token_fp", chassisauth.Fingerprint(token),
|
||||||
|
"path", r.URL.Path,
|
||||||
|
)
|
||||||
ctx := withCaller(r.Context(), login)
|
ctx := withCaller(r.Context(), login)
|
||||||
ctx = gitea.WithToken(ctx, token)
|
ctx = gitea.WithToken(ctx, token)
|
||||||
onValid.ServeHTTP(w, r.WithContext(ctx))
|
onValid.ServeHTTP(w, r.WithContext(ctx))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A token that is not a valid PAT falls through SILENTLY. The
|
||||||
|
// chassis owns the verdict; logging here too would double-count
|
||||||
|
// every static-token request, since all of them reach this
|
||||||
|
// middleware first.
|
||||||
}
|
}
|
||||||
fallback.ServeHTTP(w, r)
|
fallback.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubValidator struct{ login string }
|
||||||
|
|
||||||
|
func (s stubValidator) ValidateToken(context.Context, string) (string, bool) {
|
||||||
|
return s.login, s.login != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitea-mcp#60: PassthroughMiddleware logged nothing at all, success or failure.
|
||||||
|
//
|
||||||
|
// While verifying #59 end to end there was no way to confirm from the server
|
||||||
|
// logs whether calls were using the new PAT pass-through (caller's own Gitea
|
||||||
|
// PAT, own identity) or falling through to the shared JWT/static path. The only
|
||||||
|
// way to reason about it was inference. The chassis names its two paths as of
|
||||||
|
// v0.7.0; this is the third.
|
||||||
|
//
|
||||||
|
// This matters more than the other two: pass-through is the path where the
|
||||||
|
// request acts as a SPECIFIC Gitea user. An audit trail that cannot say which
|
||||||
|
// identity performed a write is not an audit trail.
|
||||||
|
func TestPassthroughMiddleware_LogsTheAcceptedPath(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
|
||||||
|
reached := false
|
||||||
|
h := PassthroughMiddleware(stubValidator{login: "alice"},
|
||||||
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }),
|
||||||
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not fall back") }))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer a-real-gitea-pat")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
require.True(t, reached)
|
||||||
|
|
||||||
|
var line map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &line))
|
||||||
|
require.Equal(t, "mcp auth accepted", line["msg"])
|
||||||
|
require.Equal(t, "passthrough", line["auth_path"])
|
||||||
|
require.Equal(t, "alice", line["caller"])
|
||||||
|
require.NotEmpty(t, line["token_fp"], "correlatable without being a credential")
|
||||||
|
|
||||||
|
for k, v := range line {
|
||||||
|
require.NotContains(t, []any{v}, "a-real-gitea-pat",
|
||||||
|
"the PAT must never appear in any field, including %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token that is not a valid PAT must fall through silently to the chassis,
|
||||||
|
// which owns the rejection log. Logging here as well would double-count every
|
||||||
|
// static-token request — every one of them reaches this middleware first.
|
||||||
|
func TestPassthroughMiddleware_FallsThroughWithoutLogging(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
|
||||||
|
fellBack := false
|
||||||
|
h := PassthroughMiddleware(stubValidator{},
|
||||||
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not accept") }),
|
||||||
|
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { fellBack = true }))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer not-a-pat")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
require.True(t, fellBack)
|
||||||
|
require.Empty(t, strings.TrimSpace(buf.String()),
|
||||||
|
"the chassis owns the verdict; logging here too would double-count every static-token call")
|
||||||
|
}
|
||||||
@@ -27,8 +27,8 @@ func TestEmptyPathSegmentRejectedBeforeNetwork(t *testing.T) {
|
|||||||
c := gitea.NewClient(srv.URL, "tok")
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/v1/repos/mathias//issues", // empty repo
|
"/api/v1/repos/mathias//issues", // empty repo
|
||||||
"/api/v1/repos//gitea-mcp/contents/", // empty owner
|
"/api/v1/repos//gitea-mcp/contents/", // empty owner
|
||||||
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
|
"/api/v1/repos/mathias//issues?state=open", // empty repo before query
|
||||||
}
|
}
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
|
|||||||
@@ -255,4 +255,3 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
|||||||
}
|
}
|
||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ func TestCreateRepo_Org(t *testing.T) {
|
|||||||
|
|
||||||
c := gitea.NewClient(srv.URL, "tok")
|
c := gitea.NewClient(srv.URL, "tok")
|
||||||
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
r, err := c.CreateRepo(context.Background(), gitea.CreateRepoArgs{
|
||||||
Name: "infra",
|
Name: "infra",
|
||||||
Org: "hyperguild",
|
Org: "hyperguild",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "hyperguild/infra", r.FullName)
|
assert.Equal(t, "hyperguild/infra", r.FullName)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProtocolVersion = "2025-03-26"
|
ProtocolVersion = "2025-03-26"
|
||||||
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type echoTool struct{}
|
|||||||
func (echoTool) Descriptor() registry.ToolDescriptor {
|
func (echoTool) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{Name: "echo", Description: "echoes its input"}
|
return registry.ToolDescriptor{Name: "echo", Description: "echoes its input"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (echoTool) Call(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (echoTool) Call(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
return args, nil
|
return args, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const branchCheckExistsResp = `{"name":"feat/x","commit":{"id":"abc","url":"http://example.com"}}`
|
const (
|
||||||
const createBranchResp = `{"name":"feat/new","commit":{"id":"abc","url":"http://example.com"}}`
|
branchCheckExistsResp = `{"name":"feat/x","commit":{"id":"abc","url":"http://example.com"}}`
|
||||||
const upsertFileResp = `{"content":{"path":"doc.md","sha":"filsha","html_url":"http://example.com/doc.md"},"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`
|
createBranchResp = `{"name":"feat/new","commit":{"id":"abc","url":"http://example.com"}}`
|
||||||
const getRepoResp = `{"name":"myrepo","full_name":"owner/myrepo","default_branch":"main"}`
|
upsertFileResp = `{"content":{"path":"doc.md","sha":"filsha","html_url":"http://example.com/doc.md"},"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`
|
||||||
|
getRepoResp = `{"name":"myrepo","full_name":"owner/myrepo","default_branch":"main"}`
|
||||||
|
)
|
||||||
|
|
||||||
func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
|
func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const (
|
|||||||
|
|
||||||
func (t *TBDShip) Descriptor() registry.ToolDescriptor {
|
func (t *TBDShip) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "tbd_ship",
|
Name: "tbd_ship",
|
||||||
Description: "Trunk-based ship of a single-file change: branch from base, write the file, open a PR, and auto-merge (squash) to base ONLY when the head commit's CI is fully green. Fails closed to PR-only (never merges) when CI is pending, red, or absent, when the base branch requires reviews, or when the merge isn't clean — returning the PR for manual handling with a reason. Set ci_timeout_seconds to poll CI to completion (default 0 = snapshot, which returns pending right after opening the PR). Returns {merged, pr_url, pr_number, ci_status, reason, branch}.",
|
Description: "Trunk-based ship of a single-file change: branch from base, write the file, open a PR, and auto-merge (squash) to base ONLY when the head commit's CI is fully green. Fails closed to PR-only (never merges) when CI is pending, red, or absent, when the base branch requires reviews, or when the merge isn't clean — returning the PR for manual handling with a reason. Set ci_timeout_seconds to poll CI to completion (default 0 = snapshot, which returns pending right after opening the PR). Returns {merged, pr_url, pr_number, ci_status, reason, branch}.",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func parseArgs(raw json.RawMessage, dst any) error {
|
|||||||
// allowlist check. Returns a typed ErrValidation naming the missing field.
|
// allowlist check. Returns a typed ErrValidation naming the missing field.
|
||||||
func validateRequiredIdentifiers(dst any) error {
|
func validateRequiredIdentifiers(dst any) error {
|
||||||
v := reflect.ValueOf(dst)
|
v := reflect.ValueOf(dst)
|
||||||
if v.Kind() != reflect.Ptr || v.IsNil() {
|
if v.Kind() != reflect.Pointer || v.IsNil() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
v = v.Elem()
|
v = v.Elem()
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ func TestWorkflowRunListTool(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "status=all is no-op",
|
name: "status=all is no-op",
|
||||||
input: `{"owner":"mathias","name":"gitea-mcp","status":"all"}`,
|
input: `{"owner":"mathias","name":"gitea-mcp","status":"all"}`,
|
||||||
notQuery: []string{"status"},
|
notQuery: []string{"status"},
|
||||||
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
||||||
assert: func(t *testing.T, out string) {},
|
assert: func(t *testing.T, out string) {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "branch filter",
|
name: "branch filter",
|
||||||
|
|||||||
Reference in New Issue
Block a user