<!-- Provide a brief summary of your changes --> ## Motivation and Context <!-- Why is this change needed? What problem does it solve? --> The following PR addresses the feedback from #634 around: * Use a proper library - https://github.com/distribution/reference, instead of the implementing the OCI parsing ourselves (note: left the tests so we can ensure we are not breaking any existing behavior) * Updating one of the error messages to now assume MCPB. ## 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) - [ ] 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 --> Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
349 lines
12 KiB
Go
349 lines
12 KiB
Go
package registries
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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.
|
|
// Supports canonical OCI references including:
|
|
// - registry/namespace/image:tag
|
|
// - registry/namespace/image@sha256:digest
|
|
// - registry/namespace/image:tag@sha256:digest
|
|
// - namespace/image:tag (defaults to docker.io)
|
|
func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) error {
|
|
if pkg.Identifier == "" {
|
|
return ErrMissingIdentifierForOCI
|
|
}
|
|
|
|
// Validate that old format fields are not present
|
|
if pkg.RegistryBaseURL != "" {
|
|
return fmt.Errorf("OCI packages must not have 'registryBaseUrl' field - use canonical reference in 'identifier' instead (e.g., 'docker.io/owner/image:1.0.0')")
|
|
}
|
|
if pkg.Version != "" {
|
|
return fmt.Errorf("OCI packages must not have 'version' field - include version in 'identifier' instead (e.g., 'docker.io/owner/image:1.0.0')")
|
|
}
|
|
if pkg.FileSHA256 != "" {
|
|
return fmt.Errorf("OCI packages must not have 'fileSha256' field")
|
|
}
|
|
|
|
// Parse the canonical OCI reference from the identifier
|
|
ociRef, err := ParseOCIReference(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)
|
|
}
|
|
|
|
// 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)
|
|
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
|
|
}
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|