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.
This commit is contained in:
Gaurav Gosain 2025-09-17 22:56:52 +04:00 • committed by GitHub
commit 2280e4da38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 244 additions and 56 deletions

View file

@ -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`).
</details>

View file

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

View file

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

View file

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