feat: implement server versioning approach with semantic versioning support (#296)
This implements the versioning approach agreed upon in issue #158: ## Key Changes ### Documentation - Added comprehensive versioning guide (docs/versioning.md) - Updated FAQ with versioning guidance and best practices - Enhanced schema description with SHOULD recommendations ### Schema Updates - Added 255-character limit for version strings in schema.json - Enhanced version field description with semantic versioning guidance ### Implementation - New isSemanticVersion() function for proper semver detection - Enhanced compareSemanticVersions() with prerelease support - Implemented compareVersions() strategy: * Both semver: use semantic comparison * Neither semver: use timestamp comparison * Mixed: semver versions always take precedence - Updated publish logic to determine "latest" using new strategy - Added version length validation (255 char max) ### Versioning Strategy 1. Version MUST be string up to 255 characters 2. SHOULD use semantic versioning for predictable ordering 3. SHOULD align with package versions to reduce confusion 4. MAY use prerelease labels for registry-specific versions 5. Registry attempts semver parsing, falls back to timestamp ordering 6. Clients SHOULD follow same comparison logic Resolves #158 --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: adam jones <domdomegg@users.noreply.github.com>
This commit is contained in:
parent
b3ae1c2ac3
commit
7b211a46d6
4 changed files with 561 additions and 164 deletions
|
|
@ -127,8 +127,18 @@ func (s *fakeRegistryService) Publish(req model.PublishRequest) (*model.ServerRe
|
|||
// Extract publisher extensions from request
|
||||
publisherExtensions := model.ExtractPublisherExtensions(req)
|
||||
|
||||
// Create registry metadata for fake service (always marks as latest)
|
||||
now := time.Now()
|
||||
registryMetadata := model.RegistryMetadata{
|
||||
ID: uuid.New().String(),
|
||||
PublishedAt: now,
|
||||
UpdatedAt: now,
|
||||
IsLatest: true,
|
||||
ReleaseDate: now.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Publish to database
|
||||
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions)
|
||||
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions, registryMetadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ package service
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/registry/internal/database"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
const maxServerVersionsPerServer = 10000
|
||||
|
||||
// registryServiceImpl implements the RegistryService interface using our Database
|
||||
type registryServiceImpl struct {
|
||||
db database.Database
|
||||
|
|
@ -81,11 +85,69 @@ func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerRe
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Get the new version's details
|
||||
newVersion := req.Server.VersionDetail.Version
|
||||
newName := req.Server.Name
|
||||
currentTime := time.Now()
|
||||
|
||||
// Check for existing versions of this server
|
||||
existingServers, _, err := s.db.List(ctx, map[string]any{"name": newName}, "", maxServerVersionsPerServer)
|
||||
if err != nil && !errors.Is(err, database.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(existingServers) == maxServerVersionsPerServer {
|
||||
return nil, database.ErrMaxServersReached
|
||||
}
|
||||
|
||||
// Determine if this version should be marked as latest
|
||||
isLatest := true
|
||||
var existingLatestID string
|
||||
|
||||
// Check all existing versions for duplicates and determine if new version should be latest
|
||||
for _, server := range existingServers {
|
||||
existingVersion := server.ServerJSON.VersionDetail.Version
|
||||
|
||||
// Early exit: check for duplicate version
|
||||
if existingVersion == newVersion {
|
||||
return nil, database.ErrInvalidVersion
|
||||
}
|
||||
|
||||
if server.RegistryMetadata.IsLatest {
|
||||
existingLatestID = server.RegistryMetadata.ID
|
||||
existingTime, _ := time.Parse(time.RFC3339, server.RegistryMetadata.ReleaseDate)
|
||||
|
||||
// Compare versions using the proper versioning strategy
|
||||
comparison := CompareVersions(newVersion, existingVersion, currentTime, existingTime)
|
||||
if comparison <= 0 {
|
||||
// New version is not greater than existing latest
|
||||
isLatest = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract publisher extensions from request
|
||||
publisherExtensions := model.ExtractPublisherExtensions(req)
|
||||
|
||||
// Publish to database
|
||||
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions)
|
||||
// Create registry metadata with service-determined values
|
||||
registryMetadata := model.RegistryMetadata{
|
||||
ID: uuid.New().String(),
|
||||
PublishedAt: currentTime,
|
||||
UpdatedAt: currentTime,
|
||||
IsLatest: isLatest,
|
||||
ReleaseDate: currentTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// If this will be the latest version, we need to update the existing latest
|
||||
if isLatest && existingLatestID != "" {
|
||||
// Update the existing latest to no longer be latest
|
||||
if err := s.db.UpdateLatestFlag(ctx, existingLatestID, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Publish to database with the registry metadata
|
||||
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions, registryMetadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -93,4 +155,4 @@ func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerRe
|
|||
// Convert ServerRecord to ServerResponse format
|
||||
response := serverRecord.ToServerResponse()
|
||||
return &response, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
internal/service/versioning.go
Normal file
86
internal/service/versioning.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// IsSemanticVersion checks if a version string follows semantic versioning format
|
||||
// Uses the official golang.org/x/mod/semver package for validation
|
||||
// Requires exactly three parts: major.minor.patch (optionally with prerelease/build)
|
||||
func IsSemanticVersion(version string) bool {
|
||||
// The semver package requires a "v" prefix, so add it for validation
|
||||
versionWithV := ensureVPrefix(version)
|
||||
if !semver.IsValid(versionWithV) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Additional validation: require exactly three parts (major.minor.patch)
|
||||
// Strip the v prefix and any prerelease/build metadata for counting parts
|
||||
// This ensures semver compliance, because the default go module accepts invalid semvers :/
|
||||
// (See https://pkg.go.dev/golang.org/x/mod/semver)
|
||||
versionCore := strings.TrimPrefix(versionWithV, "v")
|
||||
if idx := strings.Index(versionCore, "-"); idx != -1 {
|
||||
versionCore = versionCore[:idx]
|
||||
}
|
||||
if idx := strings.Index(versionCore, "+"); idx != -1 {
|
||||
versionCore = versionCore[:idx]
|
||||
}
|
||||
|
||||
parts := strings.Split(versionCore, ".")
|
||||
return len(parts) == 3
|
||||
}
|
||||
|
||||
// ensureVPrefix adds a "v" prefix if not present
|
||||
func ensureVPrefix(version string) string {
|
||||
if !strings.HasPrefix(version, "v") {
|
||||
return "v" + version
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// compareSemanticVersions compares two semantic version strings
|
||||
// Uses the official golang.org/x/mod/semver package for comparison
|
||||
// Returns:
|
||||
//
|
||||
// -1 if version1 < version2
|
||||
// 0 if version1 == version2
|
||||
// +1 if version1 > version2
|
||||
func compareSemanticVersions(version1 string, version2 string) int {
|
||||
// The semver package requires a "v" prefix, so add it for comparison
|
||||
v1 := ensureVPrefix(version1)
|
||||
v2 := ensureVPrefix(version2)
|
||||
return semver.Compare(v1, v2)
|
||||
}
|
||||
|
||||
// CompareVersions implements the versioning strategy agreed upon in the discussion:
|
||||
// 1. If both versions are valid semver, use semantic version comparison
|
||||
// 2. If neither are valid semver, use publication timestamp (return 0 to indicate equal for sorting)
|
||||
// 3. If one is semver and one is not, the semver version is always considered higher
|
||||
func CompareVersions(version1 string, version2 string, timestamp1 time.Time, timestamp2 time.Time) int {
|
||||
isSemver1 := IsSemanticVersion(version1)
|
||||
isSemver2 := IsSemanticVersion(version2)
|
||||
|
||||
if isSemver1 && isSemver2 {
|
||||
// Both are semver - use semantic comparison
|
||||
return compareSemanticVersions(version1, version2)
|
||||
}
|
||||
|
||||
if !isSemver1 && !isSemver2 {
|
||||
// Neither are semver - use timestamp comparison
|
||||
if timestamp1.Before(timestamp2) {
|
||||
return -1
|
||||
} else if timestamp1.After(timestamp2) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// One is semver, one is not - semver is always higher
|
||||
if isSemver1 && !isSemver2 {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
164
internal/service/versioning_test.go
Normal file
164
internal/service/versioning_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package service_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/service"
|
||||
)
|
||||
|
||||
func TestIsSemanticVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
// Valid semantic versions
|
||||
{"basic semver", "1.0.0", true},
|
||||
{"with patch", "1.2.3", true},
|
||||
{"with zeros", "0.0.0", true},
|
||||
{"large numbers", "100.200.300", true},
|
||||
{"with prerelease alpha", "1.0.0-alpha", true},
|
||||
{"with prerelease beta", "2.1.3-beta", true},
|
||||
{"with prerelease rc", "3.0.0-rc", true},
|
||||
{"with prerelease number", "1.0.0-1", true},
|
||||
{"with prerelease complex", "1.0.0-alpha.1", true},
|
||||
{"with prerelease dots", "1.0.0-beta.2.3", true},
|
||||
{"date format", "2021.11.15", true},
|
||||
{"with hyphen in prerelease", "1.0.0-pre-release", true},
|
||||
{"with v prefix", "v1.0.0", true},
|
||||
|
||||
// Invalid semantic versions
|
||||
{"empty string", "", false},
|
||||
{"single number", "1", false},
|
||||
{"two parts only", "1.0", false},
|
||||
{"four parts", "1.0.0.0", false},
|
||||
{"non-numeric major", "a.0.0", false},
|
||||
{"non-numeric minor", "1.b.0", false},
|
||||
{"non-numeric patch", "1.0.c", false},
|
||||
{"empty prerelease", "1.0.0-", false},
|
||||
{"special chars in prerelease", "1.0.0-alpha@1", false},
|
||||
{"snapshot", "snapshot", false},
|
||||
{"latest", "latest", false},
|
||||
{"with leading zeros", "2021.03.05", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := service.IsSemanticVersion(tt.version); got != tt.want {
|
||||
t.Errorf("IsSemanticVersion(%q) = %v, want %v", tt.version, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareSemanticVersions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version1 string
|
||||
version2 string
|
||||
want int
|
||||
}{
|
||||
// Major version differences
|
||||
{"major less", "1.0.0", "2.0.0", -1},
|
||||
{"major greater", "2.0.0", "1.0.0", 1},
|
||||
{"major equal", "1.0.0", "1.0.0", 0},
|
||||
|
||||
// Minor version differences
|
||||
{"minor less", "1.1.0", "1.2.0", -1},
|
||||
{"minor greater", "1.2.0", "1.1.0", 1},
|
||||
{"minor equal", "1.2.0", "1.2.0", 0},
|
||||
|
||||
// Patch version differences
|
||||
{"patch less", "1.0.1", "1.0.2", -1},
|
||||
{"patch greater", "1.0.2", "1.0.1", 1},
|
||||
{"patch equal", "1.0.1", "1.0.1", 0},
|
||||
|
||||
// Complex comparisons
|
||||
{"complex less", "1.9.9", "2.0.0", -1},
|
||||
{"complex greater", "2.0.0", "1.9.9", 1},
|
||||
{"complex mixed", "1.10.0", "1.2.0", 1},
|
||||
|
||||
// Prerelease comparisons
|
||||
{"prerelease vs stable less", "1.0.0-alpha", "1.0.0", -1},
|
||||
{"stable vs prerelease greater", "1.0.0", "1.0.0-alpha", 1},
|
||||
{"prerelease alpha vs beta", "1.0.0-alpha", "1.0.0-beta", -1},
|
||||
{"prerelease beta vs alpha", "1.0.0-beta", "1.0.0-alpha", 1},
|
||||
{"prerelease same", "1.0.0-alpha", "1.0.0-alpha", 0},
|
||||
{"prerelease numeric", "1.0.0-1", "1.0.0-2", -1},
|
||||
{"prerelease complex", "1.0.0-alpha.1", "1.0.0-alpha.2", -1},
|
||||
{"prerelease rc vs alpha", "1.0.0-rc", "1.0.0-alpha", 1},
|
||||
|
||||
// Semver spec prerelease precedence rules
|
||||
{"prerelease alpha.10 vs alpha.2", "1.0.0-alpha.10", "1.0.0-alpha.2", 1},
|
||||
{"prerelease alpha.2 vs alpha.10", "1.0.0-alpha.2", "1.0.0-alpha.10", -1},
|
||||
{"prerelease beta.100 vs beta.9", "1.0.0-beta.100", "1.0.0-beta.9", 1},
|
||||
{"numeric vs alphanumeric precedence", "1.0.0-1", "1.0.0-alpha", -1},
|
||||
{"alphanumeric vs numeric precedence", "1.0.0-alpha", "1.0.0-1", 1},
|
||||
{"shorter prerelease list precedence", "1.0.0-alpha", "1.0.0-alpha.1", -1},
|
||||
{"longer prerelease list precedence", "1.0.0-alpha.1", "1.0.0-alpha", 1},
|
||||
{"mixed numeric and alpha", "1.0.0-alpha.1.beta", "1.0.0-alpha.1.2", 1},
|
||||
{"complex prerelease ordering", "1.0.0-rc.1", "1.0.0-rc.10", -1},
|
||||
|
||||
// Edge cases
|
||||
{"zero versions", "0.0.0", "0.0.1", -1},
|
||||
{"large numbers", "100.200.300", "100.200.301", -1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
now := time.Now()
|
||||
if got := service.CompareVersions(tt.version1, tt.version2, now, now); got != tt.want {
|
||||
t.Errorf("CompareVersions(%q, %q, %v, %v) = %v, want %v", tt.version1, tt.version2, now, now, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
now := time.Now()
|
||||
earlier := now.Add(-time.Hour)
|
||||
later := now.Add(time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
version1 string
|
||||
version2 string
|
||||
timestamp1 time.Time
|
||||
timestamp2 time.Time
|
||||
want int
|
||||
}{
|
||||
// Both semantic versions
|
||||
{"both semver less", "1.0.0", "2.0.0", now, now, -1},
|
||||
{"both semver greater", "2.0.0", "1.0.0", now, now, 1},
|
||||
{"both semver equal", "1.0.0", "1.0.0", now, now, 0},
|
||||
{"both semver ignore timestamps", "1.0.0", "2.0.0", later, earlier, -1},
|
||||
|
||||
// Neither semantic versions
|
||||
{"neither semver earlier", "snapshot", "latest", earlier, later, -1},
|
||||
{"neither semver later", "snapshot", "latest", later, earlier, 1},
|
||||
{"neither semver same time", "snapshot", "latest", now, now, 0},
|
||||
{"neither semver v-prefix", "v2021.03.15", "v2021.03.16", earlier, later, -1},
|
||||
|
||||
// Mixed: one semver, one not
|
||||
{"semver vs non-semver", "1.0.0", "snapshot", now, now, 1},
|
||||
{"non-semver vs semver", "snapshot", "1.0.0", now, now, -1},
|
||||
{"semver vs snapshot", "2.0.0", "snapshot", earlier, later, 1},
|
||||
{"latest vs semver", "latest", "1.0.0", later, earlier, -1},
|
||||
{"semver prerelease vs non-semver", "1.0.0-alpha", "custom", now, now, 1},
|
||||
|
||||
// Edge cases
|
||||
{"empty vs semver", "", "1.0.0", now, now, -1},
|
||||
{"semver vs empty", "1.0.0", "", now, now, 1},
|
||||
{"both empty", "", "", now, now, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := service.CompareVersions(tt.version1, tt.version2, tt.timestamp1, tt.timestamp2); got != tt.want {
|
||||
t.Errorf("CompareVersions(%q, %q, %v, %v) = %v, want %v",
|
||||
tt.version1, tt.version2, tt.timestamp1, tt.timestamp2, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue