Skip to content

Commit ff35d12

Browse files
committed
Enforce supporting ghcr, dokcerhub and gar
Signed-off-by: Radoslav Dimitrov <[email protected]>
1 parent 6d7c48b commit ff35d12

File tree

4 files changed

+145
-144
lines changed

4 files changed

+145
-144
lines changed

docs/guides/publishing/publish-server.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,10 @@ LABEL io.modelcontextprotocol.server.name="io.github.username/server-name"
313313

314314
The identifier format is `registry/namespace/repository:tag` (e.g., `docker.io/user/app:1.0.0` or `ghcr.io/user/app:1.0.0`). The version can also be specified as a digest.
315315

316-
The official MCP registry supports any public OCI-compliant registry including Docker Hub, GitHub Container Registry (GHCR), Quay.io, Google Container Registry (GCR), Amazon ECR Public, GitLab Container Registry, and more.
316+
The official MCP registry supports:
317+
- Docker Hub (`docker.io`)
318+
- GitHub Container Registry (`ghcr.io`)
319+
- Google Artifact Registry (any `*.pkg.dev` domain)
317320

318321
</details>
319322

docs/reference/server-json/official-registry-requirements.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,10 @@ Only trusted public registries are supported. Private registries and alternative
3838
- **NPM**: `https://registry.npmjs.org` only
3939
- **PyPI**: `https://pypi.org` only
4040
- **NuGet**: `https://api.nuget.org` only
41-
- **Docker/OCI**: Any public OCI-compliant registry including:
41+
- **Docker/OCI**:
4242
- Docker Hub (`docker.io`)
4343
- GitHub Container Registry (`ghcr.io`)
44-
- Quay.io (`quay.io`)
45-
- Google Container Registry (`gcr.io`, `*.pkg.dev`)
46-
- Amazon ECR Public (`public.ecr.aws`)
47-
- GitLab Container Registry (`registry.gitlab.com`)
48-
- Any other OCI Distribution Spec compliant registry
44+
- Google Artifact Registry (`*.pkg.dev`)
4945
- **MCPB**: `https://github.com` releases and `https://gitlab.com` releases only
5046

5147
## `_meta` Namespace Restrictions

internal/validators/registries/oci.go

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"log"
88
"net/http"
9+
"strings"
910

1011
"github.com/google/go-containerregistry/pkg/authn"
1112
"github.com/google/go-containerregistry/pkg/name"
@@ -16,26 +17,35 @@ import (
1617

1718
var (
1819
ErrMissingIdentifierForOCI = errors.New("package identifier is required for OCI packages")
20+
ErrUnsupportedRegistry = errors.New("unsupported OCI registry")
1921
)
2022

2123
// ErrRateLimited is returned when a registry rate limits our requests
2224
var ErrRateLimited = errors.New("rate limited by registry")
2325

26+
// allowedOCIRegistries defines the list of supported OCI registries.
27+
// This can be expanded in the future to support additional public registries.
28+
var allowedOCIRegistries = map[string]bool{
29+
// Docker Hub (and its various endpoints)
30+
"docker.io": true,
31+
"registry-1.docker.io": true, // Docker Hub API endpoint
32+
"index.docker.io": true, // Docker Hub index
33+
// GitHub Container Registry
34+
"ghcr.io": true,
35+
// Google Artifact Registry (*.pkg.dev pattern handled in isAllowedRegistry)
36+
}
37+
2438
// ValidateOCI validates that an OCI image contains the correct MCP server name annotation.
2539
// Supports canonical OCI references including:
2640
// - registry/namespace/image:tag
2741
// - registry/namespace/image@sha256:digest
2842
// - registry/namespace/image:tag@sha256:digest
2943
// - namespace/image:tag (defaults to docker.io)
3044
//
31-
// This validator now supports ANY public OCI-compliant registry including:
45+
// Supported registries:
3246
// - Docker Hub (docker.io)
3347
// - GitHub Container Registry (ghcr.io)
34-
// - Quay.io (quay.io)
35-
// - Google Container Registry (gcr.io, artifacts.dev)
36-
// - Amazon ECR Public (public.ecr.aws)
37-
// - GitLab Container Registry (registry.gitlab.com)
38-
// - Any other OCI Distribution Spec compliant registry
48+
// - Google Artifact Registry (*.pkg.dev)
3949
func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) error {
4050
if pkg.Identifier == "" {
4151
return ErrMissingIdentifierForOCI
@@ -59,6 +69,12 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
5969
return fmt.Errorf("invalid OCI reference: %w", err)
6070
}
6171

72+
// Validate that the registry is in the allowlist
73+
registry := ref.Context().RegistryStr()
74+
if !isAllowedRegistry(registry) {
75+
return fmt.Errorf("%w: %s", ErrUnsupportedRegistry, registry)
76+
}
77+
6278
// Fetch the image using anonymous authentication (public images only)
6379
// The go-containerregistry library handles:
6480
// - OCI auth discovery via WWW-Authenticate headers
@@ -103,3 +119,20 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
103119

104120
return nil
105121
}
122+
123+
// isAllowedRegistry checks if the given registry is in the allowlist.
124+
// It handles registry aliases and wildcard patterns (e.g., *.pkg.dev for Artifact Registry).
125+
func isAllowedRegistry(registry string) bool {
126+
// Direct match
127+
if allowedOCIRegistries[registry] {
128+
return true
129+
}
130+
131+
// Check for wildcard patterns
132+
// Google Artifact Registry: *.pkg.dev (e.g., us-docker.pkg.dev, europe-west1-docker.pkg.dev)
133+
if strings.HasSuffix(registry, ".pkg.dev") {
134+
return true
135+
}
136+
137+
return false
138+
}

internal/validators/registries/oci_test.go

Lines changed: 100 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -9,105 +9,109 @@ import (
99
"github.com/stretchr/testify/assert"
1010
)
1111

12-
func TestValidateOCI_RealPackages(t *testing.T) {
12+
func TestValidateOCI_RegistryAllowlist(t *testing.T) {
1313
ctx := context.Background()
1414

1515
tests := []struct {
16-
name string
17-
identifier string
18-
serverName string
19-
expectError bool
20-
errorMessage string
21-
skip bool
22-
skipReason string
16+
name string
17+
identifier string
18+
expectError bool
19+
errorMsg string
2320
}{
21+
// Allowed registries - these should NOT fail with "unsupported registry"
22+
{
23+
name: "Docker Hub should be allowed",
24+
identifier: "docker.io/test/image:latest",
25+
// Will fail on image not found, but registry should be accepted
26+
expectError: true,
27+
},
28+
{
29+
name: "Docker Hub without explicit registry should default and be allowed",
30+
identifier: "test/image:latest",
31+
// Will fail on image not found, but registry should be accepted
32+
expectError: true,
33+
},
34+
{
35+
name: "GHCR should be allowed",
36+
identifier: "ghcr.io/test/image:latest",
37+
// Will fail on image fetch, but registry should be accepted
38+
expectError: true,
39+
},
40+
{
41+
name: "Artifact Registry us-central1 should be allowed",
42+
identifier: "us-central1-docker.pkg.dev/project/repo/image:latest",
43+
// Will fail on image fetch, but registry should be accepted
44+
expectError: true,
45+
},
46+
{
47+
name: "Artifact Registry europe-west1 should be allowed",
48+
identifier: "europe-west1-docker.pkg.dev/project/repo/image:latest",
49+
// Will fail on image fetch, but registry should be accepted
50+
expectError: true,
51+
},
52+
{
53+
name: "Artifact Registry multi-region us should be allowed",
54+
identifier: "us-docker.pkg.dev/project/repo/image:latest",
55+
// Will fail on image fetch, but registry should be accepted
56+
expectError: true,
57+
},
58+
59+
// Disallowed registries
2460
{
25-
name: "empty package identifier should fail",
26-
identifier: "",
27-
serverName: "com.example/test",
28-
expectError: true,
29-
errorMessage: "package identifier is required for OCI packages",
30-
},
31-
{
32-
name: "real image with correct MCP annotation should pass (Docker Hub)",
33-
identifier: "docker.io/domdomegg/airtable-mcp-server:1.7.2",
34-
serverName: "io.github.domdomegg/airtable-mcp-server",
35-
expectError: false,
36-
skip: true,
37-
skipReason: "Skipping to avoid hitting DockerHub rate limits in CI",
38-
},
39-
{
40-
name: "GHCR image with correct MCP annotation should pass",
41-
identifier: "ghcr.io/nkapila6/mcp-local-rag:latest",
42-
serverName: "io.github.nkapila6/mcp-local-rag",
43-
expectError: false,
44-
skip: true,
45-
skipReason: "Skipping to avoid network dependencies in CI",
46-
},
47-
{
48-
name: "image without MCP annotation should fail",
49-
identifier: "docker.io/library/nginx:latest",
50-
serverName: "com.example/test",
51-
expectError: true,
52-
errorMessage: "missing required annotation",
53-
skip: true,
54-
skipReason: "Skipping to avoid hitting DockerHub rate limits in CI",
55-
},
56-
{
57-
name: "non-existent image should fail",
58-
identifier: "docker.io/nonexistent/doesnotexist:v99.99.99",
59-
serverName: "com.example/test",
60-
expectError: true,
61-
errorMessage: "not found",
62-
skip: true,
63-
skipReason: "Skipping to avoid network dependencies in CI",
64-
},
65-
{
66-
name: "Quay.io registry should be supported",
67-
identifier: "quay.io/test/image:v1.0.0",
68-
serverName: "com.example/test",
69-
expectError: true, // Will fail because image doesn't exist, but registry should be accepted
70-
errorMessage: "not found",
71-
skip: true,
72-
skipReason: "Skipping to avoid network dependencies in CI",
73-
},
74-
{
75-
name: "GCR registry should be supported",
76-
identifier: "gcr.io/test/image:v1.0.0",
77-
serverName: "com.example/test",
78-
expectError: true, // Will fail because image doesn't exist, but registry should be accepted
79-
errorMessage: "not found",
80-
skip: true,
81-
skipReason: "Skipping to avoid network dependencies in CI",
82-
},
83-
{
84-
name: "GitLab registry should be supported",
85-
identifier: "registry.gitlab.com/test/image:v1.0.0",
86-
serverName: "com.example/test",
87-
expectError: true, // Will fail because image doesn't exist, but registry should be accepted
88-
errorMessage: "not found",
89-
skip: true,
90-
skipReason: "Skipping to avoid network dependencies in CI",
61+
name: "GCR should be rejected",
62+
identifier: "gcr.io/test/image:latest",
63+
expectError: true,
64+
errorMsg: "unsupported OCI registry",
65+
},
66+
{
67+
name: "Quay.io should be rejected",
68+
identifier: "quay.io/test/image:latest",
69+
expectError: true,
70+
errorMsg: "unsupported OCI registry",
71+
},
72+
{
73+
name: "ECR Public should be rejected",
74+
identifier: "public.ecr.aws/test/image:latest",
75+
expectError: true,
76+
errorMsg: "unsupported OCI registry",
77+
},
78+
{
79+
name: "GitLab registry should be rejected",
80+
identifier: "registry.gitlab.com/test/image:latest",
81+
expectError: true,
82+
errorMsg: "unsupported OCI registry",
83+
},
84+
{
85+
name: "Custom registry should be rejected",
86+
identifier: "custom-registry.com/test/image:latest",
87+
expectError: true,
88+
errorMsg: "unsupported OCI registry",
89+
},
90+
{
91+
name: "Harbor registry should be rejected",
92+
identifier: "harbor.example.com/test/image:latest",
93+
expectError: true,
94+
errorMsg: "unsupported OCI registry",
9195
},
9296
}
9397

9498
for _, tt := range tests {
9599
t.Run(tt.name, func(t *testing.T) {
96-
if tt.skip {
97-
t.Skip(tt.skipReason)
98-
}
99-
100100
pkg := model.Package{
101101
RegistryType: model.RegistryTypeOCI,
102102
Identifier: tt.identifier,
103103
}
104104

105-
err := registries.ValidateOCI(ctx, pkg, tt.serverName)
105+
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
106106

107107
if tt.expectError {
108108
assert.Error(t, err)
109-
if tt.errorMessage != "" {
110-
assert.Contains(t, err.Error(), tt.errorMessage)
109+
if tt.errorMsg != "" {
110+
// Should contain the specific error message
111+
assert.Contains(t, err.Error(), tt.errorMsg)
112+
} else {
113+
// For allowed registries, should NOT be "unsupported registry" error
114+
assert.NotContains(t, err.Error(), "unsupported OCI registry")
111115
}
112116
} else {
113117
assert.NoError(t, err)
@@ -116,39 +120,6 @@ func TestValidateOCI_RealPackages(t *testing.T) {
116120
}
117121
}
118122

119-
func TestValidateOCI_AllRegistriesSupported(t *testing.T) {
120-
ctx := context.Background()
121-
122-
// Test that various registry formats are accepted (they will fail on fetch, not on validation)
123-
testRegistries := []string{
124-
"docker.io/test/image:latest",
125-
"ghcr.io/test/image:latest",
126-
"quay.io/test/image:latest",
127-
"gcr.io/test/image:latest",
128-
"public.ecr.aws/test/image:latest",
129-
"registry.gitlab.com/test/image:latest",
130-
"custom-registry.com/test/image:latest",
131-
}
132-
133-
for _, registry := range testRegistries {
134-
t.Run(registry, func(t *testing.T) {
135-
pkg := model.Package{
136-
RegistryType: model.RegistryTypeOCI,
137-
Identifier: registry,
138-
}
139-
140-
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
141-
142-
// Should NOT fail with "unsupported registry" error
143-
// Will fail with "not found" or similar, but that means the registry was accepted
144-
if err != nil {
145-
assert.NotContains(t, err.Error(), "unsupported registry")
146-
assert.NotContains(t, err.Error(), "registry type and base URL do not match")
147-
}
148-
})
149-
}
150-
}
151-
152123
func TestValidateOCI_RejectsOldFormat(t *testing.T) {
153124
ctx := context.Background()
154125

@@ -184,29 +155,14 @@ func TestValidateOCI_RejectsOldFormat(t *testing.T) {
184155
},
185156
errorMessage: "OCI packages must not have 'fileSha256' field",
186157
},
187-
{
188-
name: "OCI package with canonical format should pass format validation",
189-
pkg: model.Package{
190-
RegistryType: model.RegistryTypeOCI,
191-
Identifier: "docker.io/test/image:latest",
192-
},
193-
errorMessage: "", // Should pass old format check (will fail later due to image not existing)
194-
},
195158
}
196159

197160
for _, tt := range tests {
198161
t.Run(tt.name, func(t *testing.T) {
199162
err := registries.ValidateOCI(ctx, tt.pkg, "com.example/test")
200163

201-
if tt.errorMessage != "" {
202-
assert.Error(t, err)
203-
assert.Contains(t, err.Error(), tt.errorMessage)
204-
} else if err != nil {
205-
// Should not fail with old format error (may fail with other errors like image not found)
206-
assert.NotContains(t, err.Error(), "must not have 'registryBaseUrl'")
207-
assert.NotContains(t, err.Error(), "must not have 'version'")
208-
assert.NotContains(t, err.Error(), "must not have 'fileSha256'")
209-
}
164+
assert.Error(t, err)
165+
assert.Contains(t, err.Error(), tt.errorMessage)
210166
})
211167
}
212168
}
@@ -241,3 +197,16 @@ func TestValidateOCI_InvalidReferences(t *testing.T) {
241197
})
242198
}
243199
}
200+
201+
func TestValidateOCI_EmptyIdentifier(t *testing.T) {
202+
ctx := context.Background()
203+
204+
pkg := model.Package{
205+
RegistryType: model.RegistryTypeOCI,
206+
Identifier: "",
207+
}
208+
209+
err := registries.ValidateOCI(ctx, pkg, "com.example/test")
210+
assert.Error(t, err)
211+
assert.Contains(t, err.Error(), "package identifier is required")
212+
}

0 commit comments

Comments
 (0)