Skip to content

Commit 939a1af

Browse files
committed
wip
🏠 Remote-Dev: homespace
1 parent c4abcf6 commit 939a1af

File tree

12 files changed

+238
-422
lines changed

12 files changed

+238
-422
lines changed

internal/api/handlers/v0/publish_integration_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,15 +223,16 @@ func TestPublishIntegration(t *testing.T) {
223223

224224
t.Run("publish succeeds with MCPB package", func(t *testing.T) {
225225
publishReq := apiv0.ServerJSON{
226-
Name: "com.example/test-server-mcpb",
226+
Name: "io.github.domdomegg/airtable-mcp-server",
227227
Description: "A test server with MCPB package",
228228
VersionDetail: model.VersionDetail{
229-
Version: "1.0.0",
229+
Version: "1.7.2",
230230
},
231+
Status: model.StatusActive,
231232
Packages: []model.Package{
232233
{
233234
RegistryType: model.RegistryTypeMCPB,
234-
Identifier: "https://github.com/example/server/releases/download/v1.0.0/server.tar.gz",
235+
Identifier: "github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb",
235236
},
236237
},
237238
}

internal/validators/constants.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@ var (
1414
ErrInvalidRemoteURL = errors.New("invalid remote URL")
1515

1616
// Registry validation errors
17-
ErrUnsupportedRegistryType = errors.New("unsupported registry type")
1817
ErrUnsupportedRegistryBaseURL = errors.New("unsupported registry base URL")
1918
ErrMismatchedRegistryTypeAndURL = errors.New("registry type and base URL do not match")
2019

2120
// Argument validation errors
22-
ErrNamedArgumentNameRequired = errors.New("named argument name is required")
23-
ErrInvalidNamedArgumentName = errors.New("invalid named argument name format")
24-
ErrArgumentValueStartsWithName = errors.New("argument value cannot start with the argument name")
21+
ErrNamedArgumentNameRequired = errors.New("named argument name is required")
22+
ErrInvalidNamedArgumentName = errors.New("invalid named argument name format")
23+
ErrArgumentValueStartsWithName = errors.New("argument value cannot start with the argument name")
2524
ErrArgumentDefaultStartsWithName = errors.New("argument default cannot start with the argument name")
2625
)
2726

internal/validators/package.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import (
88
"github.com/modelcontextprotocol/registry/pkg/model"
99
)
1010

11-
// ValidatePackageOwnership validates that the package referenced in the server configuration
12-
// is owned by the publisher, by checking for a matching server name in the package metadata.
13-
func ValidatePackageOwnership(ctx context.Context, pkg model.Package, serverName string) error {
11+
// ValidatePackage validates that the package referenced in the server configuration is:
12+
// 1. allowed on the official registry (based on registry base url); and
13+
// 2. owned by the publisher, by checking for a matching server name in the package metadata
14+
func ValidatePackage(ctx context.Context, pkg model.Package, serverName string) error {
1415
switch pkg.RegistryType {
1516
case model.RegistryTypeNPM:
1617
return registries.ValidateNPM(ctx, pkg, serverName)

internal/validators/registries/mcpb.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,31 @@ import (
55
"fmt"
66
"net/http"
77
"net/url"
8+
"regexp"
89
"strings"
910
"time"
1011

1112
"github.com/modelcontextprotocol/registry/pkg/model"
1213
)
1314

1415
func ValidateMCPB(ctx context.Context, pkg model.Package, _ string) error {
16+
err := validateMCPBUrl(pkg.Identifier)
17+
if err != nil {
18+
return err
19+
}
20+
21+
inferredBaseURL, err := inferMCPBRegistryBaseURL(pkg.Identifier)
22+
if err != nil {
23+
return err
24+
}
25+
26+
if pkg.RegistryBaseURL == "" {
27+
pkg.RegistryBaseURL = inferredBaseURL
28+
} else if pkg.RegistryBaseURL != inferredBaseURL {
29+
return fmt.Errorf("MCPB package '%s' has inconsistent registry base URL: %s (expected: %s)",
30+
pkg.Identifier, pkg.RegistryBaseURL, inferredBaseURL)
31+
}
32+
1533
// Parse the URL to validate format
1634
url, err := url.Parse(pkg.Identifier)
1735
if err != nil {
@@ -47,3 +65,103 @@ func ValidateMCPB(ctx context.Context, pkg model.Package, _ string) error {
4765

4866
return nil
4967
}
68+
69+
func validateMCPBUrl(fullURL string) error {
70+
parsedURL, err := url.Parse(fullURL)
71+
if err != nil {
72+
return fmt.Errorf("invalid MCPB package URL: %w", err)
73+
}
74+
75+
host := strings.ToLower(parsedURL.Host)
76+
allowedHosts := []string{
77+
"github.com",
78+
"www.github.com",
79+
"gitlab.com",
80+
"www.gitlab.com",
81+
}
82+
83+
isAllowed := false
84+
for _, allowed := range allowedHosts {
85+
if host == allowed {
86+
isAllowed = true
87+
break
88+
}
89+
}
90+
91+
if !isAllowed {
92+
return fmt.Errorf("MCPB packages must be hosted on allowlisted providers (GitHub or GitLab). Host '%s' is not allowed", host)
93+
}
94+
95+
// Validate URL path is a proper release URL with strict structure validation
96+
path := parsedURL.Path
97+
switch host {
98+
case "github.com", "www.github.com":
99+
// GitHub release URLs must match: /owner/repo/releases/download/tag/filename
100+
if !isValidGitHubReleaseURL(path) {
101+
return fmt.Errorf("GitHub MCPB packages must be release assets following the pattern '/owner/repo/releases/download/tag/filename'")
102+
}
103+
case "gitlab.com", "www.gitlab.com":
104+
// GitLab release URLs must match specific patterns
105+
if !isValidGitLabReleaseURL(path) {
106+
return fmt.Errorf("GitLab MCPB packages must be release assets following patterns '/owner/repo/-/releases/tag/downloads/filename' or '/owner/repo/-/package_files/id/download'")
107+
}
108+
}
109+
110+
return nil
111+
}
112+
113+
// isValidGitHubReleaseURL validates that a path follows the GitHub release asset pattern
114+
// Pattern: /owner/repo/releases/download/tag/filename
115+
func isValidGitHubReleaseURL(path string) bool {
116+
// GitHub release URL pattern: /owner/repo/releases/download/tag/filename
117+
// - owner: username or organization (1-39 chars, alphanumeric + hyphens, no consecutive hyphens)
118+
// - repo: repository name (similar rules to owner)
119+
// - tag: release tag (can contain various characters but not empty)
120+
// - filename: asset filename (not empty)
121+
pattern := `^/([a-zA-Z0-9]([a-zA-Z0-9\-]{0,37}[a-zA-Z0-9])?)/([a-zA-Z0-9._\-]+)/releases/download/([^/]+)/([^/]+)$`
122+
matched, _ := regexp.MatchString(pattern, path)
123+
return matched
124+
}
125+
126+
// isValidGitLabReleaseURL validates that a path follows GitLab release asset patterns
127+
func isValidGitLabReleaseURL(path string) bool {
128+
// GitLab release URL patterns:
129+
// 1. /owner/repo/-/releases/tag/downloads/filename
130+
// 2. /owner/repo/-/package_files/id/download
131+
// 3. /group/subgroup/repo/-/releases/tag/downloads/filename (nested groups)
132+
133+
// The key insight is that GitLab URLs have "/-/" as a delimiter that separates the
134+
// project path from the GitLab-specific routes. Everything before "/-/" is the project path.
135+
136+
// Pattern 1: Release downloads with /-/releases/tag/downloads/filename
137+
releasePattern := `^/([a-zA-Z0-9._\-]+(?:/[a-zA-Z0-9._\-]+)*)/-/releases/([^/]+)/downloads/([^/]+)$`
138+
if matched, _ := regexp.MatchString(releasePattern, path); matched {
139+
return true
140+
}
141+
142+
// Pattern 2: Package files with /-/package_files/id/download
143+
packagePattern := `^/([a-zA-Z0-9._\-]+(?:/[a-zA-Z0-9._\-]+)*)/-/package_files/([0-9]+)/download$`
144+
if matched, _ := regexp.MatchString(packagePattern, path); matched {
145+
return true
146+
}
147+
148+
return false
149+
}
150+
151+
// inferMCPBRegistryBaseURL infers the registry base URL from an MCPB identifier
152+
func inferMCPBRegistryBaseURL(identifier string) (string, error) {
153+
parsedURL, err := url.Parse(identifier)
154+
if err != nil {
155+
return "", err
156+
}
157+
158+
host := strings.ToLower(parsedURL.Host)
159+
switch host {
160+
case "github.com", "www.github.com":
161+
return model.RegistryURLGitHub, nil
162+
case "gitlab.com", "www.gitlab.com":
163+
return model.RegistryURLGitLab, nil
164+
default:
165+
return "", fmt.Errorf("invalid host for MCPB package: %s, expected github or gitlab", host)
166+
}
167+
}

internal/validators/registries/mcpb_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func TestValidateMCPB(t *testing.T) {
4747
},
4848
{
4949
name: "invalid URL format should fail",
50-
packageName: "not a valid url for mcpb!",
50+
packageName: "not://a valid url for mcpb!",
5151
serverName: "com.example/test",
5252
expectError: true,
5353
errorMessage: "invalid MCPB package URL",

internal/validators/registries/npm.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,20 @@ type NPMPackageResponse struct {
1717

1818
// ValidateNPM validates that an NPM package contains the correct MCP server name
1919
func ValidateNPM(ctx context.Context, pkg model.Package, serverName string) error {
20-
baseURL := pkg.RegistryBaseURL
21-
if baseURL == "" {
22-
baseURL = model.RegistryURLNPM
20+
// Set default registry base URL if empty
21+
if pkg.RegistryBaseURL == "" {
22+
pkg.RegistryBaseURL = model.RegistryURLNPM
23+
}
24+
25+
// Validate that the registry base URL matches NPM exactly
26+
if pkg.RegistryBaseURL != model.RegistryURLNPM {
27+
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
28+
pkg.RegistryBaseURL, model.RegistryTypeNPM, model.RegistryURLNPM)
2329
}
2430

2531
client := &http.Client{Timeout: 10 * time.Second}
2632

27-
url := baseURL + "/" + pkg.Identifier + "/" + pkg.Version
33+
url := pkg.RegistryBaseURL + "/" + pkg.Identifier + "/" + pkg.Version
2834
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
2935
if err != nil {
3036
return fmt.Errorf("failed to create request: %w", err)

internal/validators/registries/nuget.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ import (
1313

1414
// ValidateNuGet validates that a NuGet package contains the correct MCP server name
1515
func ValidateNuGet(ctx context.Context, pkg model.Package, serverName string) error {
16-
baseURL := pkg.RegistryBaseURL
17-
if baseURL == "" {
18-
baseURL = model.RegistryURLNuGet
16+
// Set default registry base URL if empty
17+
if pkg.RegistryBaseURL == "" {
18+
pkg.RegistryBaseURL = model.RegistryURLNuGet
19+
}
20+
21+
// Validate that the registry base URL matches NuGet exactly
22+
if pkg.RegistryBaseURL != model.RegistryURLNuGet {
23+
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
24+
pkg.RegistryBaseURL, model.RegistryTypeNuGet, model.RegistryURLNuGet)
1925
}
2026

2127
client := &http.Client{Timeout: 10 * time.Second}
@@ -27,7 +33,7 @@ func ValidateNuGet(ctx context.Context, pkg model.Package, serverName string) er
2733
}
2834

2935
// Try to get README from the package
30-
readmeURL := fmt.Sprintf("%s/v3-flatcontainer/%s/%s/readme", baseURL, lowerID, lowerVersion)
36+
readmeURL := fmt.Sprintf("%s/v3-flatcontainer/%s/%s/readme", pkg.RegistryBaseURL, lowerID, lowerVersion)
3137
req, err := http.NewRequestWithContext(ctx, http.MethodGet, readmeURL, nil)
3238
if err != nil {
3339
return fmt.Errorf("failed to create request: %w", err)

internal/validators/registries/oci.go

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,35 +39,33 @@ type OCIImageConfig struct {
3939

4040
// ValidateOCI validates that an OCI image contains the correct MCP server name annotation
4141
func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) error {
42-
client := &http.Client{Timeout: 10 * time.Second}
42+
// Set default registry base URL if empty
43+
if pkg.RegistryBaseURL == "" {
44+
pkg.RegistryBaseURL = model.RegistryURLDocker
45+
}
4346

44-
// Parse image reference (namespace/repo or repo)
45-
parts := strings.Split(pkg.Identifier, "/")
46-
var namespace, repo string
47-
switch len(parts) {
48-
case 2:
49-
namespace = parts[0]
50-
repo = parts[1]
51-
case 1:
52-
namespace = "library"
53-
repo = pkg.Identifier
54-
default:
55-
return fmt.Errorf("invalid image reference: %s", pkg.Identifier)
47+
// Validate that the registry base URL matches OCI/Docker exactly
48+
if pkg.RegistryBaseURL != model.RegistryURLDocker {
49+
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
50+
pkg.RegistryBaseURL, model.RegistryTypeOCI, model.RegistryURLDocker)
5651
}
5752

58-
// Get image manifest
59-
tag := pkg.Version
60-
if tag == "" {
61-
tag = "latest"
53+
client := &http.Client{Timeout: 10 * time.Second}
54+
55+
// Parse image reference (namespace/repo or repo)
56+
namespace, repo, err := parseImageReference(pkg.Identifier)
57+
if err != nil {
58+
return fmt.Errorf("invalid OCI image reference: %w", err)
6259
}
6360

6461
apiBaseURL := pkg.RegistryBaseURL
65-
if pkg.RegistryBaseURL == model.RegistryURLDocker || pkg.RegistryBaseURL == "" {
62+
if pkg.RegistryBaseURL == model.RegistryURLDocker {
6663
// docker.io is an exceptional registry that was created before standardisation, so needs a custom API base url
6764
// https://github.com/containers/image/blob/5e4845dddd57598eb7afeaa6e0f4c76531bd3c91/docker/docker_client.go#L225-L229
6865
apiBaseURL = dockerIoAPIBaseURL
6966
}
7067

68+
tag := pkg.Version
7169
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", apiBaseURL, namespace, repo, tag)
7270
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
7371
if err != nil {
@@ -93,9 +91,12 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
9391
}
9492
defer resp.Body.Close()
9593

96-
if resp.StatusCode != http.StatusOK {
94+
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized {
9795
return fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode)
9896
}
97+
if resp.StatusCode != http.StatusOK {
98+
return fmt.Errorf("failed to fetch OCI manifest (status: %d)", resp.StatusCode)
99+
}
99100

100101
var manifest OCIManifest
101102
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
@@ -137,6 +138,18 @@ func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) erro
137138
return nil
138139
}
139140

141+
func parseImageReference(identifier string) (string, string, error) {
142+
parts := strings.Split(identifier, "/")
143+
switch len(parts) {
144+
case 2:
145+
return parts[0], parts[1], nil
146+
case 1:
147+
return "library", parts[0], nil
148+
default:
149+
return "", "", fmt.Errorf("invalid image reference: %s", identifier)
150+
}
151+
}
152+
140153
// getDockerIoAuthToken retrieves an authentication token from Docker Hub
141154
func getDockerIoAuthToken(ctx context.Context, client *http.Client, namespace, repo string) (string, error) {
142155
authURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s/%s:pull", namespace, repo)

internal/validators/registries/pypi.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,20 @@ type PyPIPackageResponse struct {
2020

2121
// ValidatePyPI validates that a PyPI package contains the correct MCP server name
2222
func ValidatePyPI(ctx context.Context, pkg model.Package, serverName string) error {
23-
baseURL := pkg.RegistryBaseURL
24-
if baseURL == "" {
25-
baseURL = model.RegistryURLPyPI
23+
// Set default registry base URL if empty
24+
if pkg.RegistryBaseURL == "" {
25+
pkg.RegistryBaseURL = model.RegistryURLPyPI
26+
}
27+
28+
// Validate that the registry base URL matches PyPI exactly
29+
if pkg.RegistryBaseURL != model.RegistryURLPyPI {
30+
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
31+
pkg.RegistryBaseURL, model.RegistryTypePyPI, model.RegistryURLPyPI)
2632
}
2733

2834
client := &http.Client{Timeout: 10 * time.Second}
2935

30-
url := fmt.Sprintf("%s/pypi/%s/json", baseURL, pkg.Identifier)
36+
url := fmt.Sprintf("%s/pypi/%s/json", pkg.RegistryBaseURL, pkg.Identifier)
3137
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
3238
if err != nil {
3339
return fmt.Errorf("failed to create request: %w", err)

0 commit comments

Comments
 (0)