feat(validators): Validate that Server versions are not ranges (#435)
<!-- Provide a brief summary of your changes --> Validates that server versions are not ranges. **NB:** This also introduces `validateVersion` as a check on the top-level `ValidateServerJSON`, as previously we were only checking version ranges of packages via `validatePackageField` (i.e. `latest` would have still been a valid top level server version), despite #413's wording suggesting otherwise. ## Motivation and Context Solves https://github.com/modelcontextprotocol/registry/issues/415 ## How Has This Been Tested? Tests added in `internal/validators/validators_test.go` `make check` is clean. ## Breaking Changes As per @domdomegg's comment, we should > check nobody has done this in the existing registry data (if they have, we should explore and understand why people have done this / if it's valid etc.). ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [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 <!-- Add any other context, implementation notes, or design decisions -->
This commit is contained in:
parent
a100367461
commit
78c482b8d9
6 changed files with 265 additions and 26 deletions
|
|
@ -98,8 +98,22 @@ Registry clients SHOULD:
|
|||
```javascript
|
||||
"v1.0" // Version with prefix
|
||||
"2021.03.15" // Date-based versioning
|
||||
"snapshot" // Development snapshots
|
||||
"latest" // Custom versioning scheme
|
||||
```
|
||||
|
||||
### Not Allowed: Version Ranges
|
||||
The registry requires specific versions for both the top-level `version` and any `packages[].version`. Version ranges or wildcard versions are rejected during publish, including but not limited to:
|
||||
|
||||
```javascript
|
||||
"^1.2.3"
|
||||
"~1.2.3"
|
||||
">=1.2.3"
|
||||
"<=1.2.3"
|
||||
">1.2.3"
|
||||
"<1.2.3"
|
||||
"1.x"
|
||||
"1.2.*"
|
||||
"1 - 2"
|
||||
"1.2 || 1.3"
|
||||
```
|
||||
|
||||
### Alignment Examples
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ The registry accepts any version string up to 255 characters, but we recommend:
|
|||
- **SHOULD align with package versions** to reduce confusion
|
||||
- **MAY use prerelease labels** (e.g., "1.0.0-1") for registry-specific versions
|
||||
|
||||
The registry attempts to parse versions as semantic versions for proper ordering. Non-semantic versions are allowed but will be ordered by publication timestamp. See the [versioning guide](../explanations/versioning.md) for detailed guidance.
|
||||
The registry attempts to parse versions as semantic versions for proper ordering. Non-semantic versions are allowed but will be ordered by publication timestamp. Version ranges (e.g., `^1.2.3`, `~1.2.3`, `>=1.2.3`, `1.x`, `1.*`) are rejected; publish a specific version instead. See the [versioning guide](../explanations/versioning.md) for detailed guidance.
|
||||
|
||||
### Can I add custom metadata when publishing?
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@
|
|||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"example": "1.0.2",
|
||||
"description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably."
|
||||
"description": "Version string for this server. SHOULD follow semantic versioning (e.g., '1.0.2', '2.1.0-alpha'). Equivalent of Implementation.version in MCP specification. Non-semantic versions are allowed but may not sort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')."
|
||||
},
|
||||
"website_url": {
|
||||
"type": "string",
|
||||
|
|
@ -105,7 +105,8 @@
|
|||
"type": "string",
|
||||
"description": "Package version",
|
||||
"example": "1.0.2",
|
||||
"minLength": 1
|
||||
"minLength": 1,
|
||||
"remarks": "Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')."
|
||||
},
|
||||
"file_sha256": {
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ var (
|
|||
ErrInvalidSubfolderPath = errors.New("invalid subfolder path")
|
||||
|
||||
// Package validation errors
|
||||
ErrPackageNameHasSpaces = errors.New("package name cannot contain spaces")
|
||||
ErrPackageNameHasSpaces = errors.New("package name cannot contain spaces")
|
||||
ErrReservedVersionString = errors.New("version string 'latest' is reserved and cannot be used")
|
||||
ErrVersionLooksLikeRange = errors.New("version must be a specific version, not a range")
|
||||
|
||||
// Remote validation errors
|
||||
ErrInvalidRemoteURL = errors.New("invalid remote URL")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
|
|
@ -13,12 +14,43 @@ import (
|
|||
"github.com/modelcontextprotocol/registry/pkg/model"
|
||||
)
|
||||
|
||||
// Regexes to detect semver range syntaxes
|
||||
var (
|
||||
// Case 1: comparator ranges
|
||||
// - "^1.2.3",
|
||||
// - "~1.2.3",
|
||||
// - ">=1.0.0",
|
||||
// - "<=1.0.0",
|
||||
// - ">1.0.0",
|
||||
// - "<1.0.0",
|
||||
// - "=1.0.0",
|
||||
comparatorRangeRe = regexp.MustCompile(`^\s*(?:\^|~|>=|<=|>|<|=)\s*v?\d+(?:\.\d+){0,3}(?:-[0-9A-Za-z.-]+)?\s*$`)
|
||||
// Case 2: hyphen ranges
|
||||
// - "1.2.3 - 2.0.0",
|
||||
hyphenRangeRe = regexp.MustCompile(`^\s*v?\d+(?:\.\d+){0,3}(?:-[0-9A-Za-z.-]+)?\s-\s*v?\d+(?:\.\d+){0,3}(?:-[0-9A-Za-z.-]+)?\s*$`)
|
||||
// Case 3: OR ranges
|
||||
// - "1.2 || 1.3",
|
||||
orRangeRe = regexp.MustCompile(`^\s*(?:v?\d+(?:\.\d+){0,3}(?:-[0-9A-Za-z.-]+)?\s*)(?:\|\|\s*v?\d+(?:\.\d+){0,3}(?:-[0-9A-Za-z.-]+)?\s*)+$`)
|
||||
// Case 4: dotted version wildcards
|
||||
// - "1.2.*",
|
||||
// - "1.2.x",
|
||||
// - "1.2.X",
|
||||
// - "1.x",
|
||||
// etc.
|
||||
dottedVersionLikeRe = regexp.MustCompile(`^\s*(?:v?\d+|x|X|\*)(?:\.(?:\d+|x|X|\*)){1,2}(?:-[0-9A-Za-z.-]+)?\s*$`)
|
||||
)
|
||||
|
||||
func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error {
|
||||
// Validate server name exists and format
|
||||
if _, err := parseServerName(*serverJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate top-level server version is a specific version (not a range) & not "latest"
|
||||
if err := validateVersion(serverJSON.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate repository
|
||||
if err := validateRepository(&serverJSON.Repository); err != nil {
|
||||
return err
|
||||
|
|
@ -135,16 +167,53 @@ func validatePackageField(obj *model.Package) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// validateVersion validates the version string
|
||||
// validateVersion validates the version string.
|
||||
// NB: we decided that we would not enforce strict semver for version strings
|
||||
func validateVersion(version string) error {
|
||||
if version == "latest" {
|
||||
return ErrReservedVersionString
|
||||
}
|
||||
|
||||
// Reject semver range-like inputs
|
||||
if looksLikeVersionRange(version) {
|
||||
return fmt.Errorf("%w: %q", ErrVersionLooksLikeRange, version)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// looksLikeVersionRange detects common semver range syntaxes and wildcard patterns.
|
||||
// that indicate the value is not a single, specific version.
|
||||
// Examples that should return true:
|
||||
// - "^1.2.3",
|
||||
// - "~1.2.3",
|
||||
// - ">=1.0.0",
|
||||
// - "1.x",
|
||||
// - "1.2.*",
|
||||
// - "1 - 2",
|
||||
// - "1.2 || 1.3"
|
||||
func looksLikeVersionRange(version string) bool {
|
||||
trimmed := strings.TrimSpace(version)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if comparatorRangeRe.MatchString(trimmed) {
|
||||
return true
|
||||
}
|
||||
if hyphenRangeRe.MatchString(trimmed) {
|
||||
return true
|
||||
}
|
||||
if orRangeRe.MatchString(trimmed) {
|
||||
return true
|
||||
}
|
||||
if dottedVersionLikeRe.MatchString(trimmed) {
|
||||
// wildcard in a dotted version (x/X/*) implies range-like intent
|
||||
return strings.Contains(trimmed, "x") || strings.Contains(trimmed, "X") || strings.Contains(trimmed, "*")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateArgument validates argument details
|
||||
func validateArgument(obj *model.Argument) error {
|
||||
if obj.Type == model.ArgumentTypeNamed {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,160 @@ func TestValidate(t *testing.T) {
|
|||
serverDetail apiv0.ServerJSON
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "Version rejects top-level version ranges",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "^1.2.3",
|
||||
},
|
||||
expectedError: validators.ErrVersionLooksLikeRange.Error(),
|
||||
},
|
||||
{
|
||||
name: "rejects package version ranges",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
RegistryType: model.RegistryTypeNPM,
|
||||
Version: ">=1.2.3",
|
||||
Transport: model.Transport{Type: "stdio"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: validators.ErrVersionLooksLikeRange.Error(),
|
||||
},
|
||||
{
|
||||
name: "Version allows specific versions (semver)",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.2.3",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
RegistryType: model.RegistryTypeNPM,
|
||||
Version: "1.2.3-alpha.1",
|
||||
Transport: model.Transport{Type: "stdio"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "Version allows specific non-semver versions",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "2021.03.15",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
RegistryType: model.RegistryTypeNPM,
|
||||
Version: "snapshot",
|
||||
Transport: model.Transport{Type: "stdio"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "Version rejects wildcard and x-range",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.x",
|
||||
},
|
||||
expectedError: validators.ErrVersionLooksLikeRange.Error(),
|
||||
},
|
||||
{
|
||||
name: "Version rejects wildcard *",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.*",
|
||||
},
|
||||
expectedError: validators.ErrVersionLooksLikeRange.Error(),
|
||||
},
|
||||
{
|
||||
name: "Version allows freeform version with hyphen not a range",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "snapshot - 2025.09",
|
||||
},
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "Version rejects hyphen range of two versions",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.2.3 - 2.0.0",
|
||||
},
|
||||
expectedError: validators.ErrVersionLooksLikeRange.Error(),
|
||||
},
|
||||
{
|
||||
name: "Version rejects OR range with two versions",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.2 || 1.3",
|
||||
},
|
||||
expectedError: validators.ErrVersionLooksLikeRange.Error(),
|
||||
},
|
||||
{
|
||||
name: "Version rejects comparator with space",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: ">= 1.2.3",
|
||||
},
|
||||
expectedError: validators.ErrVersionLooksLikeRange.Error(),
|
||||
},
|
||||
{
|
||||
name: "valid server detail with all fields",
|
||||
serverDetail: apiv0.ServerJSON{
|
||||
|
|
@ -455,7 +609,7 @@ func TestValidate(t *testing.T) {
|
|||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: nil,
|
||||
Remotes: nil,
|
||||
},
|
||||
|
|
@ -470,7 +624,7 @@ func TestValidate(t *testing.T) {
|
|||
URL: "https://github.com/owner/repo",
|
||||
Source: "github",
|
||||
},
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{},
|
||||
Remotes: []model.Transport{},
|
||||
},
|
||||
|
|
@ -921,7 +1075,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -939,7 +1093,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -959,7 +1113,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -978,7 +1132,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -1001,7 +1155,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -1019,7 +1173,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -1040,7 +1194,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -1059,7 +1213,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -1078,7 +1232,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -1097,7 +1251,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Remotes: []model.Transport{
|
||||
{
|
||||
Type: "streamable-http",
|
||||
|
|
@ -1112,7 +1266,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Remotes: []model.Transport{
|
||||
{
|
||||
Type: "streamable-http",
|
||||
|
|
@ -1127,7 +1281,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Remotes: []model.Transport{
|
||||
{
|
||||
Type: "sse",
|
||||
|
|
@ -1142,7 +1296,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Remotes: []model.Transport{
|
||||
{
|
||||
Type: "sse",
|
||||
|
|
@ -1157,7 +1311,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Remotes: []model.Transport{
|
||||
{
|
||||
Type: "stdio",
|
||||
|
|
@ -1171,7 +1325,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Remotes: []model.Transport{
|
||||
{
|
||||
Type: "websocket",
|
||||
|
|
@ -1187,7 +1341,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Packages: []model.Package{
|
||||
{
|
||||
Identifier: "test-package",
|
||||
|
|
@ -1206,7 +1360,7 @@ func TestValidate_TransportValidation(t *testing.T) {
|
|||
serverDetail: apiv0.ServerJSON{
|
||||
Name: "com.example/test-server",
|
||||
Description: "A test server",
|
||||
Version: "1.0.0",
|
||||
Version: "1.0.0",
|
||||
Remotes: []model.Transport{
|
||||
{
|
||||
Type: "streamable-http",
|
||||
|
|
|
|||
Loading…
Reference in a new issue