ci: bump golangci-lint to v2.13.1 to fix panic on Go 1.27 (#1561)

CI is red on `main` and on every open PR (including the Dependabot PRs
#1531, #1552–#1554) with:

```
panic: file requires newer Go version go1.27 (application built with go1.26)
golangci-lint exit with code 2
```

## Cause

`actions/setup-go` with `go-version: 'stable'` started resolving to
**go1.27.0**. The pinned golangci-lint **v2.11.4** release binary is
built with **go1.26**, and its `go/types` refuses to typecheck source
files declaring a newer language version. Nothing in the failing PRs is
at fault.

## Fix

Bump to **golangci-lint v2.13.1**, whose release binaries are built with
go1.27, so linting works under `stable` again.

That bump also pulls in newer linter versions, which surface 57 findings
this tree did not have before. Handled as follows:

| Linter | Count | Resolution |
|---|---|---|
| `goconst` | 50 | 39 were repeated literals in `_test.go` files —
goconst started counting those in v2.12.0 (golangci-lint#6480 exposed
`ignore-tests`). Fixture data like `"1.0.0"` (89 occurrences) is not a
missing constant, so `goconst.ignore-tests: true`. The other 11 were
real duplication in production code (OpenAPI tags, the `bearer` security
scheme name, `$ref`, `http`/`https`) and became named constants. |
| `gosec` G710 | 1 | **True positive, fixed.** See below. |
| `staticcheck` SA1019 | 6 | Go 1.26 deprecated `ecdsa.PublicKey.X/Y`
and `PrivateKey.D`. Annotated with the same targeted `nolint` +
rationale already used for `ScalarBaseMult` in
`cmd/publisher/auth/common.go`. |

### The open redirect is real

`TrailingSlashMiddleware` copied the entire `*url.URL` before rewriting
the path. For an absolute-form request URI — legal for a Go HTTP server
to parse, e.g. `GET http://evil.com/foo/ HTTP/1.1` — `r.URL.Scheme` and
`r.URL.Host` are populated, and they were echoed straight back in the
`Location` header. The redirect target is now built from the cleaned
path and query alone. The existing `path.Clean` hardening for
GHSA-v8vw-gw5j-w7m6 (protocol-relative `//evil.com/`) is unchanged.

### Also

Switched `gomodguard` → `gomodguard_v2`; the former is deprecated as of
v2.12.0 and was emitting a warning on every run.

## Verification

- `golangci-lint v2.13.1` against this tree: **0 issues**, no
deprecation warnings
- `make validate`: all validations passed
- `go build ./...` and tests for every touched package pass (DB-backed
tests need Postgres; left to CI)

## Note on migrating the ecdsa key paths

Moving those six sites to `PublicKey.Bytes` /
`ParseUncompressedPublicKey` / `crypto/ecdh` touches key parsing and
signature verification. It should be its own reviewed PR rather than
riding along on a CI unblock. Happy to open one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Radoslav Dimitrov 2026-08-21 00:14:13 +03:00 • committed by GitHub
commit 8759d9d8c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 68 additions and 33 deletions

View file

@ -372,7 +372,7 @@ func ParsePublicKey(algorithm, publicKey string) (*PublicKeyInfo, error) {
}
return &PublicKeyInfo{
Algorithm: AlgorithmECDSAP384,
Key: ecdsa.PublicKey{Curve: curve, X: x, Y: y},
Key: ecdsa.PublicKey{Curve: curve, X: x, Y: y}, //nolint:staticcheck // SA1019: needs crypto/ecdh refactor
}, nil
}

View file

@ -62,7 +62,7 @@ func RegisterDNSEndpoint(api huma.API, pathPrefix string, cfg *config.Config) {
Path: pathPrefix + "/auth/dns",
Summary: "Exchange DNS signature for Registry JWT",
Description: "Authenticate using DNS TXT record public key and signed timestamp",
Tags: []string{"auth"},
Tags: []string{tagAuth},
}, func(ctx context.Context, input *DNSTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.ExchangeToken(ctx, input.Body.Domain, input.Body.Timestamp, input.Body.SignedTimestamp)
if err != nil {

View file

@ -54,7 +54,7 @@ func RegisterGitHubATEndpoint(api huma.API, pathPrefix string, cfg *config.Confi
Path: pathPrefix + "/auth/github-at",
Summary: "Exchange GitHub OAuth access token for Registry JWT",
Description: "Exchange a GitHub OAuth access token for a short-lived Registry JWT token",
Tags: []string{"auth"},
Tags: []string{tagAuth},
}, func(ctx context.Context, input *GitHubTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.ExchangeToken(ctx, input.Body.GitHubToken)
if err != nil {

View file

@ -235,7 +235,7 @@ func RegisterGitHubOIDCEndpoint(api huma.API, pathPrefix string, cfg *config.Con
Path: pathPrefix + "/auth/github-oidc",
Summary: "Exchange GitHub OIDC token for Registry JWT",
Description: "Exchange a GitHub Actions OIDC token for a short-lived Registry JWT token",
Tags: []string{"auth"},
Tags: []string{tagAuth},
}, func(ctx context.Context, input *GitHubOIDCTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.ExchangeToken(ctx, input.Body.OIDCToken)
if err != nil {

View file

@ -242,7 +242,7 @@ func RegisterHTTPEndpoint(api huma.API, pathPrefix string, cfg *config.Config) {
Path: pathPrefix + "/auth/http",
Summary: "Exchange HTTP signature for Registry JWT",
Description: "Authenticate using HTTP-hosted public key and signed timestamp",
Tags: []string{"auth"},
Tags: []string{tagAuth},
}, func(ctx context.Context, input *HTTPTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.ExchangeToken(ctx, input.Body.Domain, input.Body.Timestamp, input.Body.SignedTimestamp)
if err != nil {

View file

@ -5,6 +5,9 @@ import (
"github.com/modelcontextprotocol/registry/internal/config"
)
// tagAuth is the OpenAPI tag grouping all token exchange operations.
const tagAuth = "auth"
// RegisterAuthEndpoints registers all authentication endpoints with a custom path prefix
func RegisterAuthEndpoints(api huma.API, pathPrefix string, cfg *config.Config) {
// Register GitHub access token authentication endpoint

View file

@ -43,7 +43,7 @@ func RegisterNoneEndpoint(api huma.API, pathPrefix string, cfg *config.Config) {
Path: pathPrefix + "/auth/none",
Summary: "Get anonymous Registry JWT (Development/Testing Only)",
Description: "Get a short-lived Registry JWT token for publishing and editing servers in the io.modelcontextprotocol.anonymous/* namespace. This endpoint is intended for local development and automated testing only.",
Tags: []string{"auth"},
Tags: []string{tagAuth},
}, func(ctx context.Context, _ *struct{}) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.GetAnonymousToken(ctx)
if err != nil {

View file

@ -159,7 +159,7 @@ func RegisterOIDCEndpoints(api huma.API, pathPrefix string, cfg *config.Config)
Path: pathPrefix + "/auth/oidc",
Summary: "Exchange OIDC ID token for Registry JWT",
Description: "Exchange an OIDC ID token from any configured provider for a short-lived Registry JWT token",
Tags: []string{"auth"},
Tags: []string{tagAuth},
}, func(ctx context.Context, input *OIDCTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.ExchangeToken(ctx, input.Body.OIDCToken)
if err != nil {

View file

@ -37,9 +37,9 @@ func RegisterEditEndpoints(api huma.API, pathPrefix string, registry service.Reg
Path: pathPrefix + "/servers/{serverName}/versions/{version}",
Summary: "Edit MCP server",
Description: "Update the configuration of a specific version of an existing MCP server. Requires edit permission for the server. Use PATCH /servers/{serverName}/versions/{version}/status to update status metadata.",
Tags: []string{"servers"},
Tags: []string{tagServers},
Security: []map[string][]string{
{"bearer": {}},
{securitySchemeBearer: {}},
},
}, func(ctx context.Context, input *EditServerInput) (*Response[apiv0.ServerResponse], error) {
// Extract bearer token

View file

@ -0,0 +1,12 @@
package v0
// Shared OpenAPI operation metadata, kept in one place so the generated spec
// groups operations consistently.
const (
// tagServers groups the server CRUD and status operations.
tagServers = "servers"
// securitySchemeBearer is the name of the Registry JWT bearer security
// scheme declared in the OpenAPI spec.
securitySchemeBearer = "bearer"
)

View file

@ -32,7 +32,7 @@ func RegisterPublishEndpoint(api huma.API, pathPrefix string, registry service.R
Description: "Publish a new MCP server to the registry or update an existing one",
Tags: []string{"publish"},
Security: []map[string][]string{
{"bearer": {}},
{securitySchemeBearer: {}},
},
}, func(ctx context.Context, input *PublishServerInput) (*Response[apiv0.ServerResponse], error) {
// Extract bearer token

View file

@ -90,7 +90,7 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service.
Path: pathPrefix + "/servers",
Summary: "List MCP servers",
Description: "Get a paginated list of MCP servers from the registry",
Tags: []string{"servers"},
Tags: []string{tagServers},
}, func(ctx context.Context, input *ListServersInput) (*Response[apiv0.ServerListResponse], error) {
// Build filter from input parameters
filter := &database.ServerFilter{}
@ -159,7 +159,7 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service.
Path: pathPrefix + "/servers/{serverName}/versions/{version}",
Summary: "Get specific MCP server version",
Description: "Get detailed information about a specific version of an MCP server. Use the special version 'latest' to get the latest version.",
Tags: []string{"servers"},
Tags: []string{tagServers},
}, func(ctx context.Context, input *ServerVersionDetailInput) (*Response[apiv0.ServerResponse], error) {
// URL-decode the server name
serverName, err := url.PathUnescape(input.ServerName)
@ -201,7 +201,7 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service.
Path: pathPrefix + "/servers/{serverName}/versions",
Summary: "Get all versions of an MCP server",
Description: "Get all available versions for a specific MCP server",
Tags: []string{"servers"},
Tags: []string{tagServers},
}, func(ctx context.Context, input *ServerVersionsInput) (*Response[apiv0.ServerListResponse], error) {
// URL-decode the server name
serverName, err := url.PathUnescape(input.ServerName)

View file

@ -83,9 +83,9 @@ func RegisterStatusEndpoints(api huma.API, pathPrefix string, registry service.R
Path: pathPrefix + "/servers/{serverName}/versions/{version}/status",
Summary: "Update MCP server status",
Description: "Update the status metadata of a specific version of an MCP server. Requires publish or edit permission for the server. This endpoint allows changing status and status message without requiring the full server configuration.",
Tags: []string{"servers"},
Tags: []string{tagServers},
Security: []map[string][]string{
{"bearer": {}},
{securitySchemeBearer: {}},
},
}, func(ctx context.Context, input *UpdateServerStatusInput) (*Response[apiv0.ServerResponse], error) {
// Extract bearer token
@ -198,9 +198,9 @@ func RegisterAllVersionsStatusEndpoints(api huma.API, pathPrefix string, registr
Path: pathPrefix + "/servers/{serverName}/status",
Summary: "Update status for all versions of an MCP server",
Description: "Update the status metadata of all versions of an MCP server in a single transaction. Requires publish or edit permission for the server. Either all versions are updated or none on failure.",
Tags: []string{"servers"},
Tags: []string{tagServers},
Security: []map[string][]string{
{"bearer": {}},
{securitySchemeBearer: {}},
},
}, func(ctx context.Context, input *UpdateAllVersionsStatusInput) (*Response[UpdateAllVersionsStatusResponse], error) {
// Extract bearer token

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"log"
"net/http"
"net/url"
"path"
"strings"
"time"
@ -72,11 +73,18 @@ func TrailingSlashMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only redirect if the path is not "/" and ends with a "/"
if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") {
// Build the target from the path and query alone, dropping any
// scheme/host the client may have supplied in an absolute-form
// request URI ("GET http://evil.com/foo/ HTTP/1.1"), which would
// otherwise be echoed back as an off-site redirect.
//
// path.Clean both removes the trailing slash and collapses any
// leading "//" to "/", which prevents an open-redirect via a
// protocol-relative path like "//evil.com/" (GHSA-v8vw-gw5j-w7m6).
newURL := *r.URL
newURL.Path = path.Clean(r.URL.Path)
newURL := url.URL{
Path: path.Clean(r.URL.Path),
RawQuery: r.URL.RawQuery,
}
// Use 308 Permanent Redirect to preserve the request method
http.Redirect(w, r, newURL.String(), http.StatusPermanentRedirect)

View file

@ -6,6 +6,11 @@ import (
"strings"
)
const (
schemeHTTP = "http"
schemeHTTPS = "https"
)
var (
// Regular expressions for validating repository URLs
// These regex patterns ensure the URL is in the format of a valid GitHub or GitLab repository
@ -52,8 +57,8 @@ func replaceTemplateVariables(rawURL string) string {
"{host}": "example.com",
"{port}": "8080",
"{path}": "api",
"{protocol}": "http",
"{scheme}": "http",
"{protocol}": schemeHTTP,
"{scheme}": schemeHTTP,
}
result := rawURL
@ -86,7 +91,7 @@ func IsValidURL(rawURL string) bool {
}
// Check if scheme is present (http or https)
if u.Scheme != "http" && u.Scheme != "https" {
if u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS {
return false
}
@ -153,7 +158,7 @@ func IsValidRemoteURL(rawURL string) bool {
return false
}
if u.Scheme != "https" {
if u.Scheme != schemeHTTPS {
return false
}