diff --git a/docs/guides/publishing/publish-server.md b/docs/guides/publishing/publish-server.md index ca29914..328c452 100644 --- a/docs/guides/publishing/publish-server.md +++ b/docs/guides/publishing/publish-server.md @@ -238,18 +238,36 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name" ``` ### How It Works -- Registry authenticates with Docker Hub using public token +- Registry authenticates with container registries using token-based authentication: + - **Docker Hub**: Uses `auth.docker.io` token service + - **GitHub Container Registry**: Uses `ghcr.io` token service - Fetches image manifest using Docker Registry v2 API - Checks that `io.modelcontextprotocol.server.name` annotation matches your server name - Fails if annotation is missing or doesn't match -### Example server.json +### Example server.json (Docker Hub) ```json { "name": "io.github.username/server-name", "packages": [ { "registry_type": "oci", + "registry_base_url": "https://docker.io", + "identifier": "yourusername/your-mcp-server", + "version": "1.0.0" + } + ] +} +``` + +### Example server.json (GitHub Container Registry) +```json +{ + "name": "io.github.username/server-name", + "packages": [ + { + "registry_type": "oci", + "registry_base_url": "https://ghcr.io", "identifier": "yourusername/your-mcp-server", "version": "1.0.0" } @@ -259,7 +277,7 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name" The identifier is `namespace/repository`, and version is the tag and optionally digest. -The official MCP registry currently only supports the official Docker registry (`https://docker.io`). +The official MCP registry currently supports Docker Hub (`https://docker.io`) and GitHub Container Registry (`https://ghcr.io`). diff --git a/internal/validators/registries/oci.go b/internal/validators/registries/oci.go index d1017d3..b45a03e 100644 --- a/internal/validators/registries/oci.go +++ b/internal/validators/registries/oci.go @@ -3,6 +3,7 @@ package registries import ( "context" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -14,13 +15,47 @@ import ( const ( dockerIoAPIBaseURL = "https://registry-1.docker.io" + ghcrAPIBaseURL = "https://ghcr.io" ) -// OCIAuthResponse represents the Docker Hub authentication response +// 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 { @@ -45,10 +80,9 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro 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) + // Validate that the registry base URL is supported + if err := validateRegistryURL(pkg.RegistryBaseURL); err != nil { + return err } client := &http.Client{Timeout: 10 * time.Second} @@ -59,75 +93,112 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro 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 + // Get registry configuration + registryConfig := getRegistryConfig(pkg.RegistryBaseURL, namespace, repo) + if registryConfig == nil { + return fmt.Errorf("unsupported registry: %s", pkg.RegistryBaseURL) } - tag := pkg.Version - manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", apiBaseURL, namespace, repo, tag) + // 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 fmt.Errorf("failed to create manifest request: %w", err) + return nil, 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) + // Get auth token if registry requires it + if registryConfig.AuthURL != "" { + token, err := getRegistryAuthToken(ctx, client, registryConfig) if err != nil { - return fmt.Errorf("failed to authenticate with Docker registry: %w", err) + 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,application/vnd.oci.image.manifest.v1+json") + 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 fmt.Errorf("failed to fetch OCI manifest: %w", err) + 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 fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode) + 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, skip validation for now - log.Printf("Warning: Rate limited when accessing OCI image '%s/%s:%s'. Skipping validation.", namespace, repo, tag) - return nil + // 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 fmt.Errorf("failed to fetch OCI manifest (status: %d)", resp.StatusCode) + 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 fmt.Errorf("failed to parse OCI manifest: %w", err) + 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 - 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) + 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 "", fmt.Errorf("failed to get specific manifest: %w", err) } - configDigest = specificManifest.Config.Digest - } else { - configDigest = manifest.Config.Digest + return specificManifest.Config.Digest, nil } - if configDigest == "" { - return fmt.Errorf("unable to determine image config digest for '%s/%s:%s'", namespace, repo, tag) + // 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, apiBaseURL, namespace, repo, configDigest) + config, err := getImageConfig(ctx, client, registryConfig, namespace, repo, configDigest) if err != nil { return fmt.Errorf("failed to get image config: %w", err) } @@ -156,9 +227,13 @@ func parseImageReference(identifier string) (string, string, error) { } } -// 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) +// 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 { @@ -183,19 +258,20 @@ func getDockerIoAuthToken(ctx context.Context, client *http.Client, namespace, r 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) +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 for docker.io - if apiBaseURL == dockerIoAPIBaseURL { - token, err := getDockerIoAuthToken(ctx, client, namespace, repo) + // 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 Docker registry: %w", err) + return nil, fmt.Errorf("failed to authenticate with registry: %w", err) } req.Header.Set("Authorization", "Bearer "+token) } @@ -222,18 +298,18 @@ func getSpecificManifest(ctx context.Context, client *http.Client, apiBaseURL, n } // 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) +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 for docker.io - if apiBaseURL == dockerIoAPIBaseURL { - token, err := getDockerIoAuthToken(ctx, client, namespace, repo) + // 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 Docker registry: %w", err) + return nil, fmt.Errorf("failed to authenticate with registry: %w", err) } req.Header.Set("Authorization", "Bearer "+token) } diff --git a/internal/validators/registries/oci_test.go b/internal/validators/registries/oci_test.go index cbbd20f..4e31157 100644 --- a/internal/validators/registries/oci_test.go +++ b/internal/validators/registries/oci_test.go @@ -19,6 +19,7 @@ func TestValidateOCI_RealPackages(t *testing.T) { serverName string expectError bool errorMessage string + registryURL string }{ { name: "non-existent image should fail", @@ -59,6 +60,32 @@ func TestValidateOCI_RealPackages(t *testing.T) { 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 { @@ -66,9 +93,10 @@ func TestValidateOCI_RealPackages(t *testing.T) { t.Skip("Skipping OCI registry tests because we keep hitting DockerHub rate limits") pkg := model.Package{ - RegistryType: model.RegistryTypeOCI, - Identifier: tt.packageName, - Version: tt.version, + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: tt.registryURL, + Identifier: tt.packageName, + Version: tt.version, } err := registries.ValidateOCI(ctx, pkg, tt.serverName) @@ -82,3 +110,68 @@ func TestValidateOCI_RealPackages(t *testing.T) { }) } } + +func TestValidateOCI_UnsupportedRegistry(t *testing.T) { + ctx := context.Background() + + pkg := model.Package{ + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: "https://unsupported-registry.com", + Identifier: "test/image", + Version: "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 + registryURL string + expected bool + }{ + { + name: "Docker Hub should be supported", + registryURL: model.RegistryURLDocker, + expected: true, + }, + { + name: "GHCR should be supported", + registryURL: model.RegistryURLGHCR, + expected: true, + }, + { + name: "Unsupported registry should fail", + registryURL: "https://quay.io", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg := model.Package{ + RegistryType: model.RegistryTypeOCI, + RegistryBaseURL: tt.registryURL, + Identifier: "test/image", + Version: "latest", + } + + 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") + } + }) + } +} diff --git a/pkg/model/constants.go b/pkg/model/constants.go index 816c7a0..a1b2c25 100644 --- a/pkg/model/constants.go +++ b/pkg/model/constants.go @@ -14,6 +14,7 @@ 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"