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:
Frenchie 2025-09-12 10:14:59 +10:00 • committed by GitHub
commit 78c482b8d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 265 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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