diff --git a/docs/guides/publishing/publish-server.md b/docs/guides/publishing/publish-server.md index b1ab79e9..0b12844d 100644 --- a/docs/guides/publishing/publish-server.md +++ b/docs/guides/publishing/publish-server.md @@ -313,7 +313,10 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name" The identifier format is `registry/namespace/repository:tag` (e.g., `docker.io/user/app:1.0.0` or `ghcr.io/user/app:1.0.0`). The version can also be specified as a digest. -The official MCP registry currently supports Docker Hub (`docker.io`) and GitHub Container Registry (`ghcr.io`). +The official MCP registry supports: +- Docker Hub (`docker.io`) +- GitHub Container Registry (`ghcr.io`) +- Google Artifact Registry (any `*.pkg.dev` domain) diff --git a/docs/reference/server-json/official-registry-requirements.md b/docs/reference/server-json/official-registry-requirements.md index ad3514e8..f33ed8cd 100644 --- a/docs/reference/server-json/official-registry-requirements.md +++ b/docs/reference/server-json/official-registry-requirements.md @@ -36,9 +36,12 @@ Only trusted public registries are supported. Private registries and alternative **Supported registries:** - **NPM**: `https://registry.npmjs.org` only -- **PyPI**: `https://pypi.org` only +- **PyPI**: `https://pypi.org` only - **NuGet**: `https://api.nuget.org` only -- **Docker/OCI**: `https://docker.io` only +- **Docker/OCI**: + - Docker Hub (`docker.io`) + - GitHub Container Registry (`ghcr.io`) + - Google Artifact Registry (`*.pkg.dev`) - **MCPB**: `https://github.com` releases and `https://gitlab.com` releases only ## `_meta` Namespace Restrictions diff --git a/go.mod b/go.mod index ce0288cb..1b04594a 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,11 @@ require ( github.com/caarlos0/env/v11 v11.3.1 github.com/coreos/go-oidc/v3 v3.16.0 github.com/danielgtaylor/huma/v2 v2.34.1 - github.com/distribution/reference v0.6.0 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/go-containerregistry v0.20.6 github.com/jackc/pgx/v5 v5.7.6 github.com/prometheus/client_golang v1.23.2 + github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 @@ -25,7 +26,11 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/docker/cli v28.2.2+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -34,14 +39,19 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/otlptranslator v0.0.2 // indirect github.com/prometheus/procfs v0.17.0 // indirect - github.com/rs/cors v1.11.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect diff --git a/go.sum b/go.sum index 3d844487..777e4453 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ= @@ -11,8 +13,12 @@ github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNm 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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= +github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -24,6 +30,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= +github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= @@ -44,10 +52,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -66,11 +80,15 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 h1:PeBoRj6af6xMI7qCupwFvTbbnd49V7n5YpG6pg8iDYQ= @@ -99,6 +117,7 @@ golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= @@ -111,3 +130,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= diff --git a/internal/validators/registries/oci.go b/internal/validators/registries/oci.go index 4db302ca..211e5785 100644 --- a/internal/validators/registries/oci.go +++ b/internal/validators/registries/oci.go @@ -2,79 +2,38 @@ package registries import ( "context" - "encoding/json" "errors" "fmt" "log" "net/http" + "strings" "time" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" "github.com/modelcontextprotocol/registry/pkg/model" ) var ( ErrMissingIdentifierForOCI = errors.New("package identifier is required for OCI packages") - ErrMissingVersionForOCI = errors.New("package version is required for OCI packages") -) - -const ( - dockerIoAPIBaseURL = "https://registry-1.docker.io" - ghcrAPIBaseURL = "https://ghcr.io" + ErrUnsupportedRegistry = errors.New("unsupported OCI registry") ) // ErrRateLimited is returned when a registry rate limits our requests var ErrRateLimited = errors.New("rate limited by registry") -// OCIAuthResponse represents an OCI registry authentication response -type OCIAuthResponse struct { - Token string `json:"token"` -} - -// RegistryConfig holds configuration for different OCI registries -type RegistryConfig struct { - APIBaseURL string - AuthURL string - Service string - Scope string -} - -// getRegistryConfig returns the configuration for a specific registry -func getRegistryConfig(registryBaseURL, namespace, repo string) *RegistryConfig { - switch registryBaseURL { - case model.RegistryURLDocker: - return &RegistryConfig{ - APIBaseURL: dockerIoAPIBaseURL, - AuthURL: "https://auth.docker.io/token", - Service: "registry.docker.io", - Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo), - } - case model.RegistryURLGHCR: - return &RegistryConfig{ - APIBaseURL: ghcrAPIBaseURL, - AuthURL: fmt.Sprintf("%s/token", ghcrAPIBaseURL), - Service: "ghcr.io", - Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo), - } - default: - return nil - } -} - -// OCIManifest represents an OCI image manifest -type OCIManifest struct { - Manifests []struct { - Digest string `json:"digest"` - } `json:"manifests,omitempty"` - Config struct { - Digest string `json:"digest"` - } `json:"config,omitempty"` -} - -// OCIImageConfig represents an OCI image configuration -type OCIImageConfig struct { - Config struct { - Labels map[string]string `json:"Labels"` - } `json:"config"` +// allowedOCIRegistries defines the list of supported OCI registries. +// This can be expanded in the future to support additional public registries. +var allowedOCIRegistries = map[string]bool{ + // Docker Hub (and its various endpoints) + "docker.io": true, + "registry-1.docker.io": true, // Docker Hub API endpoint + "index.docker.io": true, // Docker Hub index + // GitHub Container Registry + "ghcr.io": true, + // Google Artifact Registry (*.pkg.dev pattern handled in isAllowedRegistry) } // ValidateOCI validates that an OCI image contains the correct MCP server name annotation. @@ -83,6 +42,11 @@ type OCIImageConfig struct { // - registry/namespace/image@sha256:digest // - registry/namespace/image:tag@sha256:digest // - namespace/image:tag (defaults to docker.io) +// +// Supported registries: +// - Docker Hub (docker.io) +// - GitHub Container Registry (ghcr.io) +// - Google Artifact Registry (*.pkg.dev) func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) error { if pkg.Identifier == "" { return ErrMissingIdentifierForOCI @@ -99,139 +63,69 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro return fmt.Errorf("OCI packages must not have 'fileSha256' field") } - // Parse the canonical OCI reference from the identifier - ociRef, err := ParseOCIReference(pkg.Identifier) + // Parse the OCI reference using go-containerregistry's name package + // This handles all the complexity of reference parsing including defaults + ref, err := name.ParseReference(pkg.Identifier) if err != nil { return fmt.Errorf("invalid OCI reference: %w", err) } - // Validate that the registry is supported - registryBaseURL := ociRef.GetRegistryBaseURL() - if err := validateRegistryURL(registryBaseURL); err != nil { - return err - } - - client := &http.Client{Timeout: 10 * time.Second} - - // Get registry configuration - registryConfig := getRegistryConfig(registryBaseURL, ociRef.Namespace, ociRef.Image) - if registryConfig == nil { - return fmt.Errorf("unsupported registry: %s", registryBaseURL) + // Validate that the registry is in the allowlist + registry := ref.Context().RegistryStr() + if !isAllowedRegistry(registry) { + return fmt.Errorf("%w: %s", ErrUnsupportedRegistry, registry) } - // Determine what to use for manifest lookup: digest if available (most secure), otherwise tag - manifestRef := ociRef.Tag - if ociRef.Digest != "" { - manifestRef = ociRef.Digest - } + // Add explicit timeout to prevent hanging on slow registries + // Use a new context with timeout to avoid modifying the caller's context + timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() - // Get the image manifest - manifest, err := fetchImageManifest(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, manifestRef) + // Fetch the image using anonymous authentication (public images only) + // The go-containerregistry library handles: + // - OCI auth discovery via WWW-Authenticate headers + // - Token negotiation for different registries + // - Rate limiting and retries + // - Multi-arch manifest resolution + img, err := remote.Image(ref, remote.WithAuth(authn.Anonymous), remote.WithContext(timeoutCtx)) if err != nil { - // Handle rate limiting explicitly - skip validation - if errors.Is(err, ErrRateLimited) { - log.Printf("Skipping OCI validation for %s due to rate limiting", ociRef.String()) - return nil + // Check if this is a timeout error + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("OCI image validation timed out after 30 seconds for '%s'. The registry may be slow or unreachable", pkg.Identifier) } - return err - } - - // Get config digest from manifest - configDigest, err := getConfigDigestFromManifest(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, manifest) - if err != nil { - return err - } - - // Validate server name annotation - return validateServerNameAnnotation(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, ociRef.Tag, configDigest, serverName) -} -// validateRegistryURL validates that the registry base URL is supported -func validateRegistryURL(registryURL string) error { - if registryURL != model.RegistryURLDocker && registryURL != model.RegistryURLGHCR { - return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s or %s", - registryURL, model.RegistryTypeOCI, model.RegistryURLDocker, model.RegistryURLGHCR) - } - return nil -} - -// fetchImageManifest fetches the OCI manifest for an image -func fetchImageManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag string) (*OCIManifest, error) { - manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, tag) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create manifest request: %w", err) - } - - // Get auth token if registry requires it - if registryConfig.AuthURL != "" { - token, err := getRegistryAuthToken(ctx, client, registryConfig) - if err != nil { - return nil, fmt.Errorf("failed to authenticate with registry: %w", err) + // Check for specific HTTP status codes + var transportErr *transport.Error + if errors.As(err, &transportErr) { + switch transportErr.StatusCode { + case http.StatusTooManyRequests: + // Rate limited - skip validation to avoid blocking publishers + // This is intentional: we prioritize UX over strict validation during high traffic + log.Printf("Skipping OCI validation for %s due to rate limiting", pkg.Identifier) + return nil + case http.StatusNotFound: + return fmt.Errorf("OCI image '%s' does not exist in the registry", pkg.Identifier) + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("OCI image '%s' is private or requires authentication. Only public images are supported", pkg.Identifier) + } } - req.Header.Set("Authorization", "Bearer "+token) + return fmt.Errorf("failed to fetch OCI image: %w", err) } - req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json") - req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0") - - resp, err := client.Do(req) + // Get the image config which contains labels + configFile, err := img.ConfigFile() if err != nil { - return nil, fmt.Errorf("failed to fetch OCI manifest: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized { - return nil, fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode) - } - if resp.StatusCode == http.StatusTooManyRequests { - // Rate limited, return explicit error - log.Printf("Rate limited when accessing OCI image '%s/%s:%s'", namespace, repo, tag) - return nil, fmt.Errorf("%w: %s/%s:%s", ErrRateLimited, namespace, repo, tag) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch OCI manifest (status: %d)", resp.StatusCode) - } - - var manifest OCIManifest - if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { - return nil, fmt.Errorf("failed to parse OCI manifest: %w", err) - } - - return &manifest, nil -} - -// getConfigDigestFromManifest extracts the config digest from an OCI manifest -func getConfigDigestFromManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo string, manifest *OCIManifest) (string, error) { - // Handle multi-arch images by using first manifest - if len(manifest.Manifests) > 0 { - // This is a multi-arch image, get the specific manifest - specificManifest, err := getSpecificManifest(ctx, client, registryConfig, namespace, repo, manifest.Manifests[0].Digest) - if err != nil { - return "", fmt.Errorf("failed to get specific manifest: %w", err) - } - return specificManifest.Config.Digest, nil - } - - // For single-arch images, validate we have a config digest - if manifest.Config.Digest == "" { - return "", fmt.Errorf("manifest missing config digest - invalid or corrupted manifest") + return fmt.Errorf("failed to get image config: %w", err) } - return manifest.Config.Digest, nil -} - -// validateServerNameAnnotation validates the MCP server name annotation in the image config -func validateServerNameAnnotation(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag, configDigest, serverName string) error { - // Get image config (contains labels) - config, err := getImageConfig(ctx, client, registryConfig, namespace, repo, configDigest) - if err != nil { - return fmt.Errorf("failed to get image config: %w", err) + // Validate the MCP server name label + if configFile.Config.Labels == nil { + return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName) } - mcpName, exists := config.Config.Labels["io.modelcontextprotocol.server.name"] + mcpName, exists := configFile.Config.Labels["io.modelcontextprotocol.server.name"] if !exists { - return fmt.Errorf("OCI image '%s/%s:%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", namespace, repo, tag, serverName) + return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName) } if mcpName != serverName { @@ -241,109 +135,19 @@ func validateServerNameAnnotation(ctx context.Context, client *http.Client, regi return nil } -// getRegistryAuthToken retrieves an authentication token from a registry -func getRegistryAuthToken(ctx context.Context, client *http.Client, config *RegistryConfig) (string, error) { - if config.AuthURL == "" { - return "", nil // No auth required - } - - authURL := fmt.Sprintf("%s?service=%s&scope=%s", config.AuthURL, config.Service, config.Scope) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create auth request: %w", err) - } - - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to request auth token: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("auth request failed with status %d", resp.StatusCode) - } - - var authResp OCIAuthResponse - if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { - return "", fmt.Errorf("failed to parse auth response: %w", err) - } - - return authResp.Token, nil -} - -// getSpecificManifest retrieves a specific manifest for multi-arch images -func getSpecificManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, digest string) (*OCIManifest, error) { - manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, digest) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create specific manifest request: %w", err) - } - - // Get auth token if registry requires it - if registryConfig.AuthURL != "" { - token, err := getRegistryAuthToken(ctx, client, registryConfig) - if err != nil { - return nil, fmt.Errorf("failed to authenticate with registry: %w", err) - } - req.Header.Set("Authorization", "Bearer "+token) - } - - req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json") - req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0") - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch specific manifest: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("specific manifest not found (status: %d)", resp.StatusCode) - } - - var manifest OCIManifest - if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { - return nil, fmt.Errorf("failed to parse specific manifest: %w", err) - } - - return &manifest, nil -} - -// getImageConfig retrieves the image configuration containing labels -func getImageConfig(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, configDigest string) (*OCIImageConfig, error) { - configURL := fmt.Sprintf("%s/v2/%s/%s/blobs/%s", registryConfig.APIBaseURL, namespace, repo, configDigest) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, configURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create config request: %w", err) - } - - // Get auth token if registry requires it - if registryConfig.AuthURL != "" { - token, err := getRegistryAuthToken(ctx, client, registryConfig) - if err != nil { - return nil, fmt.Errorf("failed to authenticate with registry: %w", err) - } - req.Header.Set("Authorization", "Bearer "+token) - } - - req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json") - req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0") - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch image config: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("image config not found (status: %d)", resp.StatusCode) +// isAllowedRegistry checks if the given registry is in the allowlist. +// It handles registry aliases and wildcard patterns (e.g., *.pkg.dev for Artifact Registry). +func isAllowedRegistry(registry string) bool { + // Direct match + if allowedOCIRegistries[registry] { + return true } - var config OCIImageConfig - if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { - return nil, fmt.Errorf("failed to parse image config: %w", err) + // Check for wildcard patterns + // Google Artifact Registry: *.pkg.dev (e.g., us-docker.pkg.dev, europe-west1-docker.pkg.dev) + if strings.HasSuffix(registry, ".pkg.dev") { + return true } - return &config, nil + return false } diff --git a/internal/validators/registries/oci_ref_parser.go b/internal/validators/registries/oci_ref_parser.go deleted file mode 100644 index 7b6a1bf0..00000000 --- a/internal/validators/registries/oci_ref_parser.go +++ /dev/null @@ -1,119 +0,0 @@ -package registries - -import ( - "fmt" - "strings" - - "github.com/distribution/reference" -) - -const ( - // defaultOCINamespace is the default namespace for official images - defaultOCINamespace = "library" -) - -// OCIReference represents a parsed OCI image reference -type OCIReference struct { - Registry string // e.g., "ghcr.io", "docker.io" - Namespace string // e.g., "owner", "library" - Image string // e.g., "repo" - Tag string // e.g., "v1.0.0", "latest" (optional) - Digest string // e.g., "sha256:abc..." (optional) -} - -// ParseOCIReference parses a canonical OCI image reference using github.com/distribution/reference. -// Supported formats: -// - registry/namespace/image:tag -// - registry/namespace/image@digest -// - registry/namespace/image:tag@digest -// - namespace/image:tag (defaults to docker.io) -// - image:tag (defaults to docker.io/library) -func ParseOCIReference(ref string) (*OCIReference, error) { - if ref == "" { - return nil, fmt.Errorf("OCI reference cannot be empty") - } - - // Parse using distribution/reference - normalizes short forms to canonical - named, err := reference.ParseNormalizedNamed(ref) - if err != nil { - return nil, fmt.Errorf("invalid OCI reference format: %w", err) - } - - result := &OCIReference{} - - // Extract registry (domain) - result.Registry = reference.Domain(named) - - // Extract path (namespace/image or just image) - path := reference.Path(named) - parts := strings.Split(path, "/") - - // Parse namespace and image from path - if len(parts) == 1 { - // Single part: library/image (docker.io default namespace) - result.Namespace = defaultOCINamespace - result.Image = parts[0] - } else { - // Multiple parts: namespace/image or org/team/image - result.Namespace = strings.Join(parts[:len(parts)-1], "/") - result.Image = parts[len(parts)-1] - } - - // Extract tag if present - if tagged, ok := named.(reference.Tagged); ok { - result.Tag = tagged.Tag() - } - - // Extract digest if present - if digested, ok := named.(reference.Digested); ok { - result.Digest = digested.Digest().String() - } - - // Validate we have either a tag or digest (required for MCP registry) - if result.Tag == "" && result.Digest == "" { - return nil, fmt.Errorf("OCI reference must include either a tag or digest: %s", ref) - } - - // Default tag to "latest" if only digest is provided (for display purposes) - // Note: when pulling by digest, the tag is ignored by registries - if result.Tag == "" && result.Digest != "" { - result.Tag = "latest" - } - - return result, nil -} - -// String returns the canonical string representation of the OCI reference -func (r *OCIReference) String() string { - var sb strings.Builder - - sb.WriteString(r.Registry) - sb.WriteString("/") - sb.WriteString(r.Namespace) - sb.WriteString("/") - sb.WriteString(r.Image) - - if r.Tag != "" { - sb.WriteString(":") - sb.WriteString(r.Tag) - } - - if r.Digest != "" { - sb.WriteString("@") - sb.WriteString(r.Digest) - } - - return sb.String() -} - -// GetRegistryBaseURL returns the full registry URL (e.g., "https://docker.io" or "https://ghcr.io") -func (r *OCIReference) GetRegistryBaseURL() string { - switch r.Registry { - case "docker.io", "registry.docker.io", "index.docker.io": - return "https://docker.io" - case "ghcr.io": - return "https://ghcr.io" - default: - return "https://" + r.Registry - } -} diff --git a/internal/validators/registries/oci_ref_parser_test.go b/internal/validators/registries/oci_ref_parser_test.go deleted file mode 100644 index 71f6bafa..00000000 --- a/internal/validators/registries/oci_ref_parser_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package registries_test - -import ( - "testing" - - "github.com/modelcontextprotocol/registry/internal/validators/registries" -) - -func TestParseOCIReference(t *testing.T) { - tests := []struct { - name string - input string - want *registries.OCIReference - wantError bool - }{ - { - name: "full reference with tag", - input: "ghcr.io/owner/repo:v1.0.0", - want: ®istries.OCIReference{ - Registry: "ghcr.io", - Namespace: "owner", - Image: "repo", - Tag: "v1.0.0", - Digest: "", - }, - }, - { - name: "full reference with digest only", - input: "ghcr.io/owner/repo@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - want: ®istries.OCIReference{ - Registry: "ghcr.io", - Namespace: "owner", - Image: "repo", - Tag: "latest", // Default when only digest provided - Digest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - }, - }, - { - name: "full reference with tag and digest", - input: "ghcr.io/owner/repo:v1.0.0@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - want: ®istries.OCIReference{ - Registry: "ghcr.io", - Namespace: "owner", - Image: "repo", - Tag: "v1.0.0", - Digest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - }, - }, - { - name: "docker.io short form", - input: "owner/repo:latest", - want: ®istries.OCIReference{ - Registry: "docker.io", - Namespace: "owner", - Image: "repo", - Tag: "latest", - Digest: "", - }, - }, - { - name: "docker.io library image", - input: "postgres:16", - want: ®istries.OCIReference{ - Registry: "docker.io", - Namespace: "library", - Image: "postgres", - Tag: "16", - Digest: "", - }, - }, - { - name: "docker.io library image with digest", - input: "postgres@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - want: ®istries.OCIReference{ - Registry: "docker.io", - Namespace: "library", - Image: "postgres", - Tag: "latest", - Digest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - }, - }, - { - name: "multi-level namespace", - input: "ghcr.io/org/team/repo:v2.0.0", - want: ®istries.OCIReference{ - Registry: "ghcr.io", - Namespace: "org/team", - Image: "repo", - Tag: "v2.0.0", - Digest: "", - }, - }, - { - name: "empty reference", - input: "", - wantError: true, - }, - { - name: "no tag or digest", - input: "ghcr.io/owner/repo", - wantError: true, - }, - { - name: "invalid digest format", - input: "ghcr.io/owner/repo@md5:abc123", - wantError: true, - }, - { - name: "invalid digest length", - input: "ghcr.io/owner/repo@sha256:abc123", - wantError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := registries.ParseOCIReference(tt.input) - - if tt.wantError { - if err == nil { - t.Errorf("registries.ParseOCIReference() expected error, got nil") - } - return - } - - if err != nil { - t.Errorf("registries.ParseOCIReference() unexpected error: %v", err) - return - } - - if got.Registry != tt.want.Registry { - t.Errorf("Registry = %v, want %v", got.Registry, tt.want.Registry) - } - if got.Namespace != tt.want.Namespace { - t.Errorf("Namespace = %v, want %v", got.Namespace, tt.want.Namespace) - } - if got.Image != tt.want.Image { - t.Errorf("Image = %v, want %v", got.Image, tt.want.Image) - } - if got.Tag != tt.want.Tag { - t.Errorf("Tag = %v, want %v", got.Tag, tt.want.Tag) - } - if got.Digest != tt.want.Digest { - t.Errorf("Digest = %v, want %v", got.Digest, tt.want.Digest) - } - }) - } -} - -func TestOCIReference_String(t *testing.T) { - tests := []struct { - name string - ref *registries.OCIReference - want string - }{ - { - name: "full reference with tag", - ref: ®istries.OCIReference{ - Registry: "ghcr.io", - Namespace: "owner", - Image: "repo", - Tag: "v1.0.0", - }, - want: "ghcr.io/owner/repo:v1.0.0", - }, - { - name: "full reference with digest", - ref: ®istries.OCIReference{ - Registry: "ghcr.io", - Namespace: "owner", - Image: "repo", - Tag: "latest", - Digest: "sha256:abc123", - }, - want: "ghcr.io/owner/repo:latest@sha256:abc123", - }, - { - name: "docker.io library image", - ref: ®istries.OCIReference{ - Registry: "docker.io", - Namespace: "library", - Image: "postgres", - Tag: "16", - }, - want: "docker.io/library/postgres:16", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.ref.String(); got != tt.want { - t.Errorf("OCIReference.String() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestOCIReference_GetRegistryBaseURL(t *testing.T) { - tests := []struct { - name string - registry string - want string - }{ - { - name: "docker.io", - registry: "docker.io", - want: "https://docker.io", - }, - { - name: "ghcr.io", - registry: "ghcr.io", - want: "https://ghcr.io", - }, - { - name: "custom registry", - registry: "my-registry.com", - want: "https://my-registry.com", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ref := ®istries.OCIReference{Registry: tt.registry} - if got := ref.GetRegistryBaseURL(); got != tt.want { - t.Errorf("GetRegistryBaseURL() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/validators/registries/oci_test.go b/internal/validators/registries/oci_test.go index 76be707c..3b02fd24 100644 --- a/internal/validators/registries/oci_test.go +++ b/internal/validators/registries/oci_test.go @@ -9,125 +9,101 @@ import ( "github.com/stretchr/testify/assert" ) -func TestValidateOCI_RealPackages(t *testing.T) { +func TestValidateOCI_RegistryAllowlist(t *testing.T) { ctx := context.Background() tests := []struct { - name string - packageName string - version string - serverName string - expectError bool - errorMessage string - registryURL string + name string + identifier string + expectError bool + errorMsg string }{ + // Allowed registries - use real public images that exist + // These should fail with "missing required annotation" (no MCP label) + // NOT with "unsupported registry", "does not exist", or "is private" errors { - name: "empty package identifier should fail", - packageName: "", - version: "latest", - serverName: "com.example/test", - expectError: true, - errorMessage: "package identifier is required for OCI packages", + name: "Docker Hub should be allowed", + identifier: "docker.io/library/alpine:latest", + expectError: true, + errorMsg: "missing required annotation", }, { - name: "empty package version should fail", - packageName: "test-image", - version: "", - serverName: "com.example/test", - expectError: true, - errorMessage: "package version is required for OCI packages", + name: "Docker Hub without explicit registry should default and be allowed", + identifier: "library/hello-world:latest", + expectError: true, + errorMsg: "missing required annotation", }, { - name: "both empty identifier and version should fail with identifier error first", - packageName: "", - version: "", - serverName: "com.example/test", - expectError: true, - errorMessage: "package identifier is required for OCI packages", + name: "GHCR should be allowed", + identifier: "ghcr.io/containerbase/base:latest", + expectError: true, + errorMsg: "missing required annotation", }, { - name: "non-existent image should fail", - packageName: generateRandomImageName(), - version: "latest", - serverName: "com.example/test", - expectError: true, - errorMessage: "not found", + name: "Artifact Registry regional should be allowed", + identifier: "us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:latest", + expectError: true, + errorMsg: "missing required annotation", }, { - name: "real image without MCP annotation should fail", - packageName: "nginx", // Popular image without MCP annotation - version: "latest", - serverName: "com.example/test", - expectError: true, - errorMessage: "missing required annotation", + name: "Artifact Registry multi-region should be allowed", + identifier: "us-docker.pkg.dev/berglas/berglas/berglas:latest", + expectError: true, + errorMsg: "missing required annotation", }, + + // Disallowed registries { - name: "real image with specific tag without MCP annotation should fail", - packageName: "redis", - version: "7-alpine", // Specific tag - serverName: "com.example/test", - expectError: true, - errorMessage: "missing required annotation", + name: "GCR should be rejected", + identifier: "gcr.io/test/image:latest", + expectError: true, + errorMsg: "unsupported OCI registry", }, { - name: "namespaced image without MCP annotation should fail", - packageName: "hello-world", // Simple image for testing - version: "latest", - serverName: "com.example/test", - expectError: true, - errorMessage: "missing required annotation", + name: "Quay.io should be rejected", + identifier: "quay.io/test/image:latest", + expectError: true, + errorMsg: "unsupported OCI registry", }, { - name: "real image with correct MCP annotation should pass", - packageName: "domdomegg/airtable-mcp-server", - version: "1.7.2", - serverName: "io.github.domdomegg/airtable-mcp-server", // This should match the annotation - expectError: false, + name: "ECR Public should be rejected", + identifier: "public.ecr.aws/test/image:latest", + expectError: true, + errorMsg: "unsupported OCI registry", }, { - name: "GHCR image without MCP annotation should fail", - packageName: "actions/runner", // GitHub's action runner image (real image without MCP annotation) - version: "latest", - serverName: "com.example/test", - expectError: true, - errorMessage: "missing required annotation", - registryURL: model.RegistryURLGHCR, + name: "GitLab registry should be rejected", + identifier: "registry.gitlab.com/test/image:latest", + expectError: true, + errorMsg: "unsupported OCI registry", }, { - name: "real GHCR image without MCP annotation should fail", - packageName: "github/github-mcp-server", // Real GitHub MCP server image - version: "main", - serverName: "io.github.github/github-mcp-server", - expectError: true, - errorMessage: "missing required annotation", // Image exists but lacks MCP annotation - registryURL: model.RegistryURLGHCR, + name: "Custom registry should be rejected", + identifier: "custom-registry.com/test/image:latest", + expectError: true, + errorMsg: "unsupported OCI registry", }, { - name: "GHCR image with correct MCP annotation should pass", - packageName: "nkapila6/mcp-local-rag", // Real MCP server with proper annotation - version: "latest", - serverName: "io.github.nkapila6/mcp-local-rag", - expectError: false, - registryURL: model.RegistryURLGHCR, + name: "Harbor registry should be rejected", + identifier: "harbor.example.com/test/image:latest", + expectError: true, + errorMsg: "unsupported OCI registry", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Skip("Skipping OCI registry tests because we keep hitting DockerHub rate limits") - pkg := model.Package{ - RegistryType: model.RegistryTypeOCI, - RegistryBaseURL: tt.registryURL, - Identifier: tt.packageName, - Version: tt.version, + RegistryType: model.RegistryTypeOCI, + Identifier: tt.identifier, } - err := registries.ValidateOCI(ctx, pkg, tt.serverName) + err := registries.ValidateOCI(ctx, pkg, "com.example/test") if tt.expectError { assert.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMessage) + // Should contain the specific error message + assert.Contains(t, err.Error(), tt.errorMsg) } else { assert.NoError(t, err) } @@ -135,68 +111,6 @@ func TestValidateOCI_RealPackages(t *testing.T) { } } -func TestValidateOCI_UnsupportedRegistry(t *testing.T) { - ctx := context.Background() - - // Test with unsupported registry in canonical reference format - pkg := model.Package{ - RegistryType: model.RegistryTypeOCI, - Identifier: "unsupported-registry.com/test/image:latest", - } - - err := registries.ValidateOCI(ctx, pkg, "com.example/test") - assert.Error(t, err) - assert.Contains(t, err.Error(), "registry type and base URL do not match") - assert.Contains(t, err.Error(), "Expected: https://docker.io or https://ghcr.io") -} - -func TestValidateOCI_SupportedRegistries(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - identifier string - expected bool - }{ - { - name: "Docker Hub should be supported", - identifier: "docker.io/test/image:latest", - expected: true, - }, - { - name: "GHCR should be supported", - identifier: "ghcr.io/test/image:latest", - expected: true, - }, - { - name: "Unsupported registry should fail", - identifier: "quay.io/test/image:latest", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - pkg := model.Package{ - RegistryType: model.RegistryTypeOCI, - Identifier: tt.identifier, - } - - err := registries.ValidateOCI(ctx, pkg, "com.example/test") - if tt.expected { - // Should not fail immediately on registry validation - // (may fail later due to network/image not found, but not due to unsupported registry) - if err != nil { - assert.NotContains(t, err.Error(), "registry type and base URL do not match") - } - } else { - assert.Error(t, err) - assert.Contains(t, err.Error(), "registry type and base URL do not match") - } - }) - } -} - func TestValidateOCI_RejectsOldFormat(t *testing.T) { ctx := context.Background() @@ -224,22 +138,13 @@ func TestValidateOCI_RejectsOldFormat(t *testing.T) { errorMessage: "OCI packages must not have 'version' field", }, { - name: "OCI package with both old format fields should fail on registryBaseUrl first", - pkg: model.Package{ - RegistryType: model.RegistryTypeOCI, - RegistryBaseURL: "https://docker.io", - Identifier: "test/image", - Version: "1.0.0", - }, - errorMessage: "OCI packages must not have 'registryBaseUrl' field", - }, - { - name: "OCI package with canonical format should pass old format validation", + name: "OCI package with fileSha256 field should be rejected", pkg: model.Package{ RegistryType: model.RegistryTypeOCI, Identifier: "docker.io/test/image:latest", + FileSHA256: "abcd1234", }, - errorMessage: "", // Should pass old format check (will fail later due to image not existing) + errorMessage: "OCI packages must not have 'fileSha256' field", }, } @@ -247,14 +152,81 @@ func TestValidateOCI_RejectsOldFormat(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := registries.ValidateOCI(ctx, tt.pkg, "com.example/test") - if tt.errorMessage != "" { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMessage) - } else if err != nil { - // Should not fail with old format error (may fail with other errors like image not found) - assert.NotContains(t, err.Error(), "must not have 'registryBaseUrl'") - assert.NotContains(t, err.Error(), "must not have 'version'") + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMessage) + }) + } +} + +func TestValidateOCI_InvalidReferences(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + identifier string + }{ + { + name: "invalid characters in reference", + identifier: "docker.io/test/image:INVALID SPACE", + }, + { + name: "malformed reference", + identifier: "not-a-valid-reference::::", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg := model.Package{ + RegistryType: model.RegistryTypeOCI, + Identifier: tt.identifier, } + + err := registries.ValidateOCI(ctx, pkg, "com.example/test") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid OCI reference") }) } } + +func TestValidateOCI_EmptyIdentifier(t *testing.T) { + ctx := context.Background() + + pkg := model.Package{ + RegistryType: model.RegistryTypeOCI, + Identifier: "", + } + + err := registries.ValidateOCI(ctx, pkg, "com.example/test") + assert.Error(t, err) + assert.Contains(t, err.Error(), "package identifier is required") +} + +func TestValidateOCI_SuccessfulValidation(t *testing.T) { + ctx := context.Background() + + // Test with a real MCP server image that has the correct label + pkg := model.Package{ + RegistryType: model.RegistryTypeOCI, + Identifier: "ghcr.io/github/github-mcp-server:latest", + } + + err := registries.ValidateOCI(ctx, pkg, "io.github.github/github-mcp-server") + assert.NoError(t, err) +} + +func TestValidateOCI_LabelMismatch(t *testing.T) { + ctx := context.Background() + + // Test with a real MCP server image but wrong expected server name + // This should fail because the label doesn't match + pkg := model.Package{ + RegistryType: model.RegistryTypeOCI, + Identifier: "ghcr.io/github/github-mcp-server:latest", + } + + err := registries.ValidateOCI(ctx, pkg, "io.github.github/github-mcp-server-mismatch") + assert.Error(t, err) + assert.Contains(t, err.Error(), "ownership validation failed") + assert.Contains(t, err.Error(), "Expected annotation") +} diff --git a/internal/validators/registries/testutils_test.go b/internal/validators/registries/testutils_test.go index c1b5806e..e5206b8b 100644 --- a/internal/validators/registries/testutils_test.go +++ b/internal/validators/registries/testutils_test.go @@ -22,11 +22,3 @@ func generateRandomNuGetPackageName() string { } return fmt.Sprintf("NonExistent.Package.%s", hex.EncodeToString(bytes)[:16]) } - -func generateRandomImageName() string { - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err != nil { - return "nonexistent-image-fallback" - } - return fmt.Sprintf("nonexistent-image-%s", hex.EncodeToString(bytes)[:16]) -} diff --git a/internal/validators/validators_test.go b/internal/validators/validators_test.go index 1dc0e227..57d044c1 100644 --- a/internal/validators/validators_test.go +++ b/internal/validators/validators_test.go @@ -1628,10 +1628,10 @@ func TestValidate_RegistryTypesAndUrls(t *testing.T) { // Invalid registry types (should fail) {"invalid_maven", "io.github.domdomegg/airtable-mcp-server", "maven", model.RegistryURLNPM, "airtable-mcp-server", "1.7.2", "", true}, {"invalid_cargo", "io.github.domdomegg/time-mcp-pypi", "cargo", model.RegistryURLPyPI, "time-mcp-pypi", "1.0.1", "", true}, - {"invalid_gem", "io.github.domdomegg/airtable-mcp-server", "gem", model.RegistryURLDocker, "domdomegg/airtable-mcp-server", "1.7.2", "", true}, + {"invalid_gem", "io.github.domdomegg/airtable-mcp-server", "gem", "", "domdomegg/airtable-mcp-server", "1.7.2", "", true}, {"invalid_unknown", "io.github.domdomegg/time-mcp-server", "unknown", model.RegistryURLNuGet, "TimeMcpServer", "1.0.2", "", true}, {"invalid_blank", "io.github.domdomegg/time-mcp-server", "", model.RegistryURLNuGet, "TimeMcpServer", "1.0.2", "", true}, - {"invalid_docker", "io.github.domdomegg/airtable-mcp-server", "docker", model.RegistryURLDocker, "domdomegg/airtable-mcp-server", "1.7.2", "", true}, // should be oci + {"invalid_docker", "io.github.domdomegg/airtable-mcp-server", "docker", "", "domdomegg/airtable-mcp-server", "1.7.2", "", true}, // should be oci {"invalid_github", "io.github.domdomegg/airtable-mcp-server", "github", model.RegistryURLGitHub, "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb", "1.7.2", "", true}, // should be mcpb {"invalid_mix_1", "io.github.domdomegg/time-mcp-server", model.RegistryTypeNuGet, model.RegistryURLNPM, "TimeMcpServer", "1.0.2", "", true}, diff --git a/pkg/model/constants.go b/pkg/model/constants.go index d0aafc9c..433319ef 100644 --- a/pkg/model/constants.go +++ b/pkg/model/constants.go @@ -13,8 +13,6 @@ const ( const ( RegistryURLNPM = "https://registry.npmjs.org" RegistryURLPyPI = "https://pypi.org" - RegistryURLDocker = "https://docker.io" - RegistryURLGHCR = "https://ghcr.io" RegistryURLNuGet = "https://api.nuget.org" RegistryURLGitHub = "https://github.com" RegistryURLGitLab = "https://gitlab.com"