Fixes #96 ## Motivation and Context See #96 ## How Has This Been Tested? Has some unit and integration tests, still needs proper testing of the different registry types ## Breaking Changes Yes, new package uploads will need extra verification ## 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) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update
260 lines
8.8 KiB
Go
260 lines
8.8 KiB
Go
package registries
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
|
)
|
|
|
|
const (
|
|
dockerIoAPIBaseURL = "https://registry-1.docker.io"
|
|
)
|
|
|
|
// OCIAuthResponse represents the Docker Hub authentication response
|
|
type OCIAuthResponse struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// 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 matches OCI/Docker exactly
|
|
if pkg.RegistryBaseURL != model.RegistryURLDocker {
|
|
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
|
|
pkg.RegistryBaseURL, model.RegistryTypeOCI, model.RegistryURLDocker)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
apiBaseURL := pkg.RegistryBaseURL
|
|
if pkg.RegistryBaseURL == model.RegistryURLDocker {
|
|
// docker.io is an exceptional registry that was created before standardisation, so needs a custom API base url
|
|
// https://github.com/containers/image/blob/5e4845dddd57598eb7afeaa6e0f4c76531bd3c91/docker/docker_client.go#L225-L229
|
|
apiBaseURL = dockerIoAPIBaseURL
|
|
}
|
|
|
|
tag := pkg.Version
|
|
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", apiBaseURL, namespace, repo, tag)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create manifest request: %w", err)
|
|
}
|
|
|
|
// Get auth token for docker.io
|
|
// We only support auth for docker.io, other registries must allow unauthed requests
|
|
if apiBaseURL == dockerIoAPIBaseURL {
|
|
token, err := getDockerIoAuthToken(ctx, client, namespace, repo)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to authenticate with Docker registry: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
req.Header.Set("Accept", "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 fmt.Errorf("failed to fetch OCI manifest: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized {
|
|
return fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode)
|
|
}
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
// Rate limited, skip validation for now
|
|
log.Printf("Warning: Rate limited when accessing OCI image '%s/%s:%s'. Skipping validation.", namespace, repo, tag)
|
|
return nil
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return 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 fmt.Errorf("failed to parse OCI manifest: %w", err)
|
|
}
|
|
|
|
// Handle multi-arch images by using first manifest
|
|
var configDigest string
|
|
if len(manifest.Manifests) > 0 {
|
|
// This is a multi-arch image, get the specific manifest
|
|
specificManifest, err := getSpecificManifest(ctx, client, apiBaseURL, namespace, repo, manifest.Manifests[0].Digest)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get specific manifest: %w", err)
|
|
}
|
|
configDigest = specificManifest.Config.Digest
|
|
} else {
|
|
configDigest = manifest.Config.Digest
|
|
}
|
|
|
|
if configDigest == "" {
|
|
return fmt.Errorf("unable to determine image config digest for '%s/%s:%s'", namespace, repo, tag)
|
|
}
|
|
|
|
// Get image config (contains labels)
|
|
config, err := getImageConfig(ctx, client, apiBaseURL, 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)
|
|
}
|
|
}
|
|
|
|
// getDockerIoAuthToken retrieves an authentication token from Docker Hub
|
|
func getDockerIoAuthToken(ctx context.Context, client *http.Client, namespace, repo string) (string, error) {
|
|
authURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s/%s:pull", namespace, repo)
|
|
|
|
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, apiBaseURL, namespace, repo, digest string) (*OCIManifest, error) {
|
|
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", 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 for docker.io
|
|
if apiBaseURL == dockerIoAPIBaseURL {
|
|
token, err := getDockerIoAuthToken(ctx, client, namespace, repo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to authenticate with Docker 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, apiBaseURL, namespace, repo, configDigest string) (*OCIImageConfig, error) {
|
|
configURL := fmt.Sprintf("%s/v2/%s/%s/blobs/%s", 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 for docker.io
|
|
if apiBaseURL == dockerIoAPIBaseURL {
|
|
token, err := getDockerIoAuthToken(ctx, client, namespace, repo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to authenticate with Docker 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
|
|
}
|