First real port of the MCP chassis library — abort-criterion check for spike S3 of the 2026-05 homelab architecture review. Changes: - Drop internal/auth/jwt.go (~79 LOC) — chassis provides JWTValidator with identical signature. - Drop internal/auth/bearer.go (~42 LOC) — chassis BearerMiddleware has the same static-or-JWT semantics plus an optional WWW-Authenticate resource_metadata challenge (consumed via new resourceMetadataURL arg). - Drop internal/auth/bearer_test.go — same scenarios are covered in the chassis bearer_test.go now. - main.go: import chassis as `chassisauth`, build resourceMetadataURL only when both DexIssuerURL + MCPResourceURL are set, replace the inline /.well-known/oauth-protected-resource handler with the chassis ProtectedResourceHandler. internal/auth/caller.go (oauth2-proxy header → context) stays — chassis out-of-scope. Net LOC change: -~150 LOC duplicated infra + a 5-LOC import. go.mod gains gitea.d-ma.be/mathias/mcp-chassis v0.1.0 (jwx/v2 + testify already transitive, no new top-level deps). Verifies abort criterion: one PR, one binary's worth of port, task check green (lint + test + vet + govulncheck clean). Per the S3 spike spec, this clears the chassis to continue. Next port: hyperguild/ingestion (brain-mcp), filed as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
113 lines
4.4 KiB
Go
113 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
|
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/config"
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
|
)
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
logger.Error("load config", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
jwtValidator, err := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
|
if err != nil {
|
|
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
|
|
}
|
|
|
|
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
|
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
|
|
|
reg := registry.New()
|
|
reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewDirList(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewBranchList(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewBranchDelete(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewBranchProtectionGet(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewPRCreate(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewPRList(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewPRMerge(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
|
|
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
|
|
reg.Register(tools.NewTagCreate(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewIssueGet(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewIssueList(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewIssueClose(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewIssueReopen(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewWorkflowRunList(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
|
|
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
|
|
|
|
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)(
|
|
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
|
auth.CallerMiddleware(mcpSrv),
|
|
),
|
|
))
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
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", "0.1.0")
|
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
logger.Error("server stopped", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|