Add support for Google Artifact Registry (GAR) (#692)

<!-- Provide a brief summary of your changes -->

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
The following PR:
* Deprecates the existing OCI reference parser
* Deprecates the existing OCI way of authenticating and accessing the
image manifest
* Replaces the above by leveraging go-containerregistry
* Introduces support for Google Artifact Registry
* Enables us to easily add support for other OCI-compliant registries in
the future
* Introduces better error handling
* Introduces custom context timeouts for validation 

## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->

## Breaking Changes
<!-- Will users need to update their code or configurations? -->

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [ ] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->
Related to #427

---------

Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
This commit is contained in:
Radoslav Dimitrov 2025-10-28 15:00:06 +02:00 • committed by GitHub
commit 913147be3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 258 additions and 803 deletions

View file

@ -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
// Validate that the registry is in the allowlist
registry := ref.Context().RegistryStr()
if !isAllowedRegistry(registry) {
return fmt.Errorf("%w: %s", ErrUnsupportedRegistry, registry)
}
client := &http.Client{Timeout: 10 * time.Second}
// 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 registry configuration
registryConfig := getRegistryConfig(registryBaseURL, ociRef.Namespace, ociRef.Image)
if registryConfig == nil {
return fmt.Errorf("unsupported registry: %s", registryBaseURL)
}
// Determine what to use for manifest lookup: digest if available (most secure), otherwise tag
manifestRef := ociRef.Tag
if ociRef.Digest != "" {
manifestRef = ociRef.Digest
}
// 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)
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 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)
// Get the image config which contains labels
configFile, err := img.ConfigFile()
if err != nil {
return fmt.Errorf("failed to get image config: %w", err)
}
mcpName, exists := config.Config.Labels["io.modelcontextprotocol.server.name"]
// 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 := 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
// 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
}
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)
// 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
}
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)
}
var config OCIImageConfig
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
return nil, fmt.Errorf("failed to parse image config: %w", err)
}
return &config, nil
return false
}

View file

@ -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
}
}

View file

@ -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: &registries.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: &registries.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: &registries.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: &registries.OCIReference{
Registry: "docker.io",
Namespace: "owner",
Image: "repo",
Tag: "latest",
Digest: "",
},
},
{
name: "docker.io library image",
input: "postgres:16",
want: &registries.OCIReference{
Registry: "docker.io",
Namespace: "library",
Image: "postgres",
Tag: "16",
Digest: "",
},
},
{
name: "docker.io library image with digest",
input: "postgres@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
want: &registries.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: &registries.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: &registries.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: &registries.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: &registries.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 := &registries.OCIReference{Registry: tt.registry}
if got := ref.GetRegistryBaseURL(); got != tt.want {
t.Errorf("GetRegistryBaseURL() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -9,169 +9,85 @@ 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",
},
{
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: "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: "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: "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: "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: "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,
},
}
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,
}
err := registries.ValidateOCI(ctx, pkg, tt.serverName)
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMessage)
} else {
assert.NoError(t, err)
}
})
}
}
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
}{
// Disallowed registries
{
name: "Docker Hub should be supported",
identifier: "docker.io/test/image:latest",
expected: true,
name: "GCR should be rejected",
identifier: "gcr.io/test/image:latest",
expectError: true,
errorMsg: "unsupported OCI registry",
},
{
name: "GHCR should be supported",
identifier: "ghcr.io/test/image:latest",
expected: true,
name: "Quay.io should be rejected",
identifier: "quay.io/test/image:latest",
expectError: true,
errorMsg: "unsupported OCI registry",
},
{
name: "Unsupported registry should fail",
identifier: "quay.io/test/image:latest",
expected: false,
name: "ECR Public should be rejected",
identifier: "public.ecr.aws/test/image:latest",
expectError: true,
errorMsg: "unsupported OCI registry",
},
{
name: "GitLab registry should be rejected",
identifier: "registry.gitlab.com/test/image:latest",
expectError: true,
errorMsg: "unsupported OCI registry",
},
{
name: "Custom registry should be rejected",
identifier: "custom-registry.com/test/image:latest",
expectError: true,
errorMsg: "unsupported OCI registry",
},
{
name: "Harbor registry should be rejected",
identifier: "harbor.example.com/test/image:latest",
expectError: true,
errorMsg: "unsupported OCI registry",
},
}
@ -183,15 +99,13 @@ func TestValidateOCI_SupportedRegistries(t *testing.T) {
}
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 {
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), "registry type and base URL do not match")
// Should contain the specific error message
assert.Contains(t, err.Error(), tt.errorMsg)
} else {
assert.NoError(t, err)
}
})
}
@ -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")
}

View file

@ -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])
}

View file

@ -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},