registry/internal/validators/registries/oci.go
Gaurav Gosain 2280e4da38
Add GitHub Container Registry (GHCR) support for MCP servers (#439)
This commit implements support for publishing MCP servers to GitHub
Container
Registry (ghcr.io) in addition to the existing Docker Hub support,
addressing
Docker Hub rate limiting concerns as requested in #393.

Key changes:
- Add RegistryURLGHCR constant for https://ghcr.io
- Refactor OCI validation to support multiple container registries with
extensible RegistryConfig system
- Implement registry-agnostic authentication system that handles:
  - Docker Hub token-based authentication via auth.docker.io
  - GHCR token-based authentication via ghcr.io/token service
- Update Accept headers to support OCI image indexes for multi-arch
images
- Extract helper functions to reduce cyclomatic complexity and improve
code maintainability
- Add comprehensive test coverage for both Docker Hub and GHCR scenarios
- Include real image testing with github/github-mcp-server and
nkapila6/mcp-local-rag
- Update publishing documentation with GHCR examples and authentication
details

The implementation maintains backward compatibility with existing Docker
Hub
usage while providing a future-proof architecture for adding additional
container registries. All authentication is now handled through proper
token
services for both registries.

Fixes #393

## Motivation and Context
Docker Hub rate limiting has been causing issues for the MCP registry
when validating OCI images. Adding GHCR support provides an alternative
container registry that many MCP server authors are already using,
reducing the load on Docker Hub and providing more options for the
community.

## How Has This Been Tested?
- All existing unit tests continue to pass
- Added comprehensive test coverage for GHCR scenarios including:
  - Non-existent images (proper error handling)
  - Real images without MCP annotations (github/github-mcp-server)  
  - Real images with proper MCP annotations (nkapila6/mcp-local-rag)
  - Registry URL validation for both supported registries
- Tested authentication flow manually using curl commands
- Verified multi-arch image support with OCI index manifests

## Breaking Changes
None. This is a purely additive change that maintains full backward
compatibility with existing Docker Hub configurations.

## Types of changes
- [x] New feature (non-breaking change which adds functionality)
- [x] Documentation update

## Checklist
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed

## Additional context
The implementation uses a RegistryConfig struct to abstract
registry-specific details like API endpoints, authentication URLs, and
token services. This design makes it straightforward to add support for
additional container registries in the future (such as AWS ECR, Azure
Container Registry, etc.) by simply adding new cases to the
getRegistryConfig function.

The authentication system properly handles the different token services
used by each registry:
- Docker Hub: auth.docker.io with service=registry.docker.io
- GHCR: ghcr.io/token with service=ghcr.io

Both registries now support the full OCI specification including
multi-arch images via OCI image indexes.
2025-09-17 11:56:52 -07:00

336 lines
12 KiB
Go

package registries
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/modelcontextprotocol/registry/pkg/model"
)
const (
dockerIoAPIBaseURL = "https://registry-1.docker.io"
ghcrAPIBaseURL = "https://ghcr.io"
)
// 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"`
}
// ValidateOCI validates that an OCI image contains the correct MCP server name annotation
func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) error {
// Set default registry base URL if empty
if pkg.RegistryBaseURL == "" {
pkg.RegistryBaseURL = model.RegistryURLDocker
}
// Validate that the registry base URL is supported
if err := validateRegistryURL(pkg.RegistryBaseURL); err != nil {
return err
}
client := &http.Client{Timeout: 10 * time.Second}
// Parse image reference (namespace/repo or repo)
namespace, repo, err := parseImageReference(pkg.Identifier)
if err != nil {
return fmt.Errorf("invalid OCI image reference: %w", err)
}
// Get registry configuration
registryConfig := getRegistryConfig(pkg.RegistryBaseURL, namespace, repo)
if registryConfig == nil {
return fmt.Errorf("unsupported registry: %s", pkg.RegistryBaseURL)
}
// Get the image manifest
manifest, err := fetchImageManifest(ctx, client, registryConfig, namespace, repo, pkg.Version)
if err != nil {
// Handle rate limiting explicitly - skip validation
if errors.Is(err, ErrRateLimited) {
log.Printf("Skipping OCI validation for %s/%s:%s due to rate limiting", namespace, repo, pkg.Version)
return nil
}
return err
}
// Get config digest from manifest
configDigest, err := getConfigDigestFromManifest(ctx, client, registryConfig, namespace, repo, manifest)
if err != nil {
return err
}
// Validate server name annotation
return validateServerNameAnnotation(ctx, client, registryConfig, namespace, repo, pkg.Version, 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)
}
req.Header.Set("Authorization", "Bearer "+token)
}
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)
if err != nil {
return fmt.Errorf("failed to get image config: %w", err)
}
mcpName, exists := config.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)
}
if mcpName != serverName {
return fmt.Errorf("OCI image ownership validation failed. Expected annotation 'io.modelcontextprotocol.server.name' = '%s', got '%s'", serverName, mcpName)
}
return nil
}
func parseImageReference(identifier string) (string, string, error) {
parts := strings.Split(identifier, "/")
switch len(parts) {
case 2:
return parts[0], parts[1], nil
case 1:
return "library", parts[0], nil
default:
return "", "", fmt.Errorf("invalid image reference: %s", identifier)
}
}
// 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)
}
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
}