Skip to content

Commit 2280e4d

Browse files
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.
1 parent 516e2ab commit 2280e4d

File tree

4 files changed

+244
-56
lines changed

4 files changed

+244
-56
lines changed

docs/guides/publishing/publish-server.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,18 +238,36 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name"
238238
```
239239

240240
### How It Works
241-
- Registry authenticates with Docker Hub using public token
241+
- Registry authenticates with container registries using token-based authentication:
242+
- **Docker Hub**: Uses `auth.docker.io` token service
243+
- **GitHub Container Registry**: Uses `ghcr.io` token service
242244
- Fetches image manifest using Docker Registry v2 API
243245
- Checks that `io.modelcontextprotocol.server.name` annotation matches your server name
244246
- Fails if annotation is missing or doesn't match
245247

246-
### Example server.json
248+
### Example server.json (Docker Hub)
249+
```json
250+
{
251+
"name": "io.github.username/server-name",
252+
"packages": [
253+
{
254+
"registry_type": "oci",
255+
"registry_base_url": "https://docker.io",
256+
"identifier": "yourusername/your-mcp-server",
257+
"version": "1.0.0"
258+
}
259+
]
260+
}
261+
```
262+
263+
### Example server.json (GitHub Container Registry)
247264
```json
248265
{
249266
"name": "io.github.username/server-name",
250267
"packages": [
251268
{
252269
"registry_type": "oci",
270+
"registry_base_url": "https://ghcr.io",
253271
"identifier": "yourusername/your-mcp-server",
254272
"version": "1.0.0"
255273
}
@@ -259,7 +277,7 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name"
259277

260278
The identifier is `namespace/repository`, and version is the tag and optionally digest.
261279

262-
The official MCP registry currently only supports the official Docker registry (`https://docker.io`).
280+
The official MCP registry currently supports Docker Hub (`https://docker.io`) and GitHub Container Registry (`https://ghcr.io`).
263281

264282
</details>
265283

internal/validators/registries/oci.go

Lines changed: 126 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package registries
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"log"
89
"net/http"
@@ -14,13 +15,47 @@ import (
1415

1516
const (
1617
dockerIoAPIBaseURL = "https://registry-1.docker.io"
18+
ghcrAPIBaseURL = "https://ghcr.io"
1719
)
1820

19-
// OCIAuthResponse represents the Docker Hub authentication response
21+
// ErrRateLimited is returned when a registry rate limits our requests
22+
var ErrRateLimited = errors.New("rate limited by registry")
23+
24+
// OCIAuthResponse represents an OCI registry authentication response
2025
type OCIAuthResponse struct {
2126
Token string `json:"token"`
2227
}
2328

29+
// RegistryConfig holds configuration for different OCI registries
30+
type RegistryConfig struct {
31+
APIBaseURL string
32+
AuthURL string
33+
Service string
34+
Scope string
35+
}
36+
37+
// getRegistryConfig returns the configuration for a specific registry
38+
func getRegistryConfig(registryBaseURL, namespace, repo string) *RegistryConfig {
39+
switch registryBaseURL {
40+
case model.RegistryURLDocker:
41+
return &RegistryConfig{
42+
APIBaseURL: dockerIoAPIBaseURL,
43+
AuthURL: "https://auth.docker.io/token",
44+
Service: "registry.docker.io",
45+
Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo),
46+
}
47+
case model.RegistryURLGHCR:
48+
return &RegistryConfig{
49+
APIBaseURL: ghcrAPIBaseURL,
50+
AuthURL: fmt.Sprintf("%s/token", ghcrAPIBaseURL),
51+
Service: "ghcr.io",
52+
Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo),
53+
}
54+
default:
55+
return nil
56+
}
57+
}
58+
2459
// OCIManifest represents an OCI image manifest
2560
type OCIManifest struct {
2661
Manifests []struct {
@@ -45,10 +80,9 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
4580
pkg.RegistryBaseURL = model.RegistryURLDocker
4681
}
4782

48-
// Validate that the registry base URL matches OCI/Docker exactly
49-
if pkg.RegistryBaseURL != model.RegistryURLDocker {
50-
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
51-
pkg.RegistryBaseURL, model.RegistryTypeOCI, model.RegistryURLDocker)
83+
// Validate that the registry base URL is supported
84+
if err := validateRegistryURL(pkg.RegistryBaseURL); err != nil {
85+
return err
5286
}
5387

5488
client := &http.Client{Timeout: 10 * time.Second}
@@ -59,75 +93,112 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
5993
return fmt.Errorf("invalid OCI image reference: %w", err)
6094
}
6195

62-
apiBaseURL := pkg.RegistryBaseURL
63-
if pkg.RegistryBaseURL == model.RegistryURLDocker {
64-
// docker.io is an exceptional registry that was created before standardisation, so needs a custom API base url
65-
// https://github.com/containers/image/blob/5e4845dddd57598eb7afeaa6e0f4c76531bd3c91/docker/docker_client.go#L225-L229
66-
apiBaseURL = dockerIoAPIBaseURL
96+
// Get registry configuration
97+
registryConfig := getRegistryConfig(pkg.RegistryBaseURL, namespace, repo)
98+
if registryConfig == nil {
99+
return fmt.Errorf("unsupported registry: %s", pkg.RegistryBaseURL)
100+
}
101+
102+
// Get the image manifest
103+
manifest, err := fetchImageManifest(ctx, client, registryConfig, namespace, repo, pkg.Version)
104+
if err != nil {
105+
// Handle rate limiting explicitly - skip validation
106+
if errors.Is(err, ErrRateLimited) {
107+
log.Printf("Skipping OCI validation for %s/%s:%s due to rate limiting", namespace, repo, pkg.Version)
108+
return nil
109+
}
110+
return err
111+
}
112+
113+
// Get config digest from manifest
114+
configDigest, err := getConfigDigestFromManifest(ctx, client, registryConfig, namespace, repo, manifest)
115+
if err != nil {
116+
return err
117+
}
118+
119+
// Validate server name annotation
120+
return validateServerNameAnnotation(ctx, client, registryConfig, namespace, repo, pkg.Version, configDigest, serverName)
121+
}
122+
123+
// validateRegistryURL validates that the registry base URL is supported
124+
func validateRegistryURL(registryURL string) error {
125+
if registryURL != model.RegistryURLDocker && registryURL != model.RegistryURLGHCR {
126+
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s or %s",
127+
registryURL, model.RegistryTypeOCI, model.RegistryURLDocker, model.RegistryURLGHCR)
67128
}
129+
return nil
130+
}
68131

69-
tag := pkg.Version
70-
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", apiBaseURL, namespace, repo, tag)
132+
// fetchImageManifest fetches the OCI manifest for an image
133+
func fetchImageManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag string) (*OCIManifest, error) {
134+
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, tag)
71135
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
72136
if err != nil {
73-
return fmt.Errorf("failed to create manifest request: %w", err)
137+
return nil, fmt.Errorf("failed to create manifest request: %w", err)
74138
}
75139

76-
// Get auth token for docker.io
77-
// We only support auth for docker.io, other registries must allow unauthed requests
78-
if apiBaseURL == dockerIoAPIBaseURL {
79-
token, err := getDockerIoAuthToken(ctx, client, namespace, repo)
140+
// Get auth token if registry requires it
141+
if registryConfig.AuthURL != "" {
142+
token, err := getRegistryAuthToken(ctx, client, registryConfig)
80143
if err != nil {
81-
return fmt.Errorf("failed to authenticate with Docker registry: %w", err)
144+
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
82145
}
83146
req.Header.Set("Authorization", "Bearer "+token)
84147
}
85148

86-
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json")
149+
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")
87150
req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0")
88151

89152
resp, err := client.Do(req)
90153
if err != nil {
91-
return fmt.Errorf("failed to fetch OCI manifest: %w", err)
154+
return nil, fmt.Errorf("failed to fetch OCI manifest: %w", err)
92155
}
93156
defer resp.Body.Close()
94157

95158
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized {
96-
return fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode)
159+
return nil, fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode)
97160
}
98161
if resp.StatusCode == http.StatusTooManyRequests {
99-
// Rate limited, skip validation for now
100-
log.Printf("Warning: Rate limited when accessing OCI image '%s/%s:%s'. Skipping validation.", namespace, repo, tag)
101-
return nil
162+
// Rate limited, return explicit error
163+
log.Printf("Rate limited when accessing OCI image '%s/%s:%s'", namespace, repo, tag)
164+
return nil, fmt.Errorf("%w: %s/%s:%s", ErrRateLimited, namespace, repo, tag)
102165
}
103166
if resp.StatusCode != http.StatusOK {
104-
return fmt.Errorf("failed to fetch OCI manifest (status: %d)", resp.StatusCode)
167+
return nil, fmt.Errorf("failed to fetch OCI manifest (status: %d)", resp.StatusCode)
105168
}
106169

107170
var manifest OCIManifest
108171
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
109-
return fmt.Errorf("failed to parse OCI manifest: %w", err)
172+
return nil, fmt.Errorf("failed to parse OCI manifest: %w", err)
110173
}
111174

175+
return &manifest, nil
176+
}
177+
178+
// getConfigDigestFromManifest extracts the config digest from an OCI manifest
179+
func getConfigDigestFromManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo string, manifest *OCIManifest) (string, error) {
112180
// Handle multi-arch images by using first manifest
113-
var configDigest string
114181
if len(manifest.Manifests) > 0 {
115182
// This is a multi-arch image, get the specific manifest
116-
specificManifest, err := getSpecificManifest(ctx, client, apiBaseURL, namespace, repo, manifest.Manifests[0].Digest)
183+
specificManifest, err := getSpecificManifest(ctx, client, registryConfig, namespace, repo, manifest.Manifests[0].Digest)
117184
if err != nil {
118-
return fmt.Errorf("failed to get specific manifest: %w", err)
185+
return "", fmt.Errorf("failed to get specific manifest: %w", err)
119186
}
120-
configDigest = specificManifest.Config.Digest
121-
} else {
122-
configDigest = manifest.Config.Digest
187+
return specificManifest.Config.Digest, nil
123188
}
124189

125-
if configDigest == "" {
126-
return fmt.Errorf("unable to determine image config digest for '%s/%s:%s'", namespace, repo, tag)
190+
// For single-arch images, validate we have a config digest
191+
if manifest.Config.Digest == "" {
192+
return "", fmt.Errorf("manifest missing config digest - invalid or corrupted manifest")
127193
}
128194

195+
return manifest.Config.Digest, nil
196+
}
197+
198+
// validateServerNameAnnotation validates the MCP server name annotation in the image config
199+
func validateServerNameAnnotation(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag, configDigest, serverName string) error {
129200
// Get image config (contains labels)
130-
config, err := getImageConfig(ctx, client, apiBaseURL, namespace, repo, configDigest)
201+
config, err := getImageConfig(ctx, client, registryConfig, namespace, repo, configDigest)
131202
if err != nil {
132203
return fmt.Errorf("failed to get image config: %w", err)
133204
}
@@ -156,9 +227,13 @@ func parseImageReference(identifier string) (string, string, error) {
156227
}
157228
}
158229

159-
// getDockerIoAuthToken retrieves an authentication token from Docker Hub
160-
func getDockerIoAuthToken(ctx context.Context, client *http.Client, namespace, repo string) (string, error) {
161-
authURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s/%s:pull", namespace, repo)
230+
// getRegistryAuthToken retrieves an authentication token from a registry
231+
func getRegistryAuthToken(ctx context.Context, client *http.Client, config *RegistryConfig) (string, error) {
232+
if config.AuthURL == "" {
233+
return "", nil // No auth required
234+
}
235+
236+
authURL := fmt.Sprintf("%s?service=%s&scope=%s", config.AuthURL, config.Service, config.Scope)
162237

163238
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil)
164239
if err != nil {
@@ -183,19 +258,20 @@ func getDockerIoAuthToken(ctx context.Context, client *http.Client, namespace, r
183258
return authResp.Token, nil
184259
}
185260

261+
186262
// getSpecificManifest retrieves a specific manifest for multi-arch images
187-
func getSpecificManifest(ctx context.Context, client *http.Client, apiBaseURL, namespace, repo, digest string) (*OCIManifest, error) {
188-
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", apiBaseURL, namespace, repo, digest)
263+
func getSpecificManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, digest string) (*OCIManifest, error) {
264+
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, digest)
189265
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
190266
if err != nil {
191267
return nil, fmt.Errorf("failed to create specific manifest request: %w", err)
192268
}
193269

194-
// Get auth token for docker.io
195-
if apiBaseURL == dockerIoAPIBaseURL {
196-
token, err := getDockerIoAuthToken(ctx, client, namespace, repo)
270+
// Get auth token if registry requires it
271+
if registryConfig.AuthURL != "" {
272+
token, err := getRegistryAuthToken(ctx, client, registryConfig)
197273
if err != nil {
198-
return nil, fmt.Errorf("failed to authenticate with Docker registry: %w", err)
274+
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
199275
}
200276
req.Header.Set("Authorization", "Bearer "+token)
201277
}
@@ -222,18 +298,18 @@ func getSpecificManifest(ctx context.Context, client *http.Client, apiBaseURL, n
222298
}
223299

224300
// getImageConfig retrieves the image configuration containing labels
225-
func getImageConfig(ctx context.Context, client *http.Client, apiBaseURL, namespace, repo, configDigest string) (*OCIImageConfig, error) {
226-
configURL := fmt.Sprintf("%s/v2/%s/%s/blobs/%s", apiBaseURL, namespace, repo, configDigest)
301+
func getImageConfig(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, configDigest string) (*OCIImageConfig, error) {
302+
configURL := fmt.Sprintf("%s/v2/%s/%s/blobs/%s", registryConfig.APIBaseURL, namespace, repo, configDigest)
227303
req, err := http.NewRequestWithContext(ctx, http.MethodGet, configURL, nil)
228304
if err != nil {
229305
return nil, fmt.Errorf("failed to create config request: %w", err)
230306
}
231307

232-
// Get auth token for docker.io
233-
if apiBaseURL == dockerIoAPIBaseURL {
234-
token, err := getDockerIoAuthToken(ctx, client, namespace, repo)
308+
// Get auth token if registry requires it
309+
if registryConfig.AuthURL != "" {
310+
token, err := getRegistryAuthToken(ctx, client, registryConfig)
235311
if err != nil {
236-
return nil, fmt.Errorf("failed to authenticate with Docker registry: %w", err)
312+
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
237313
}
238314
req.Header.Set("Authorization", "Bearer "+token)
239315
}

0 commit comments

Comments
 (0)