From b4109ed182319c6d57e3ec8f825682572dfa5210 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 22:21:36 +0000 Subject: [PATCH 1/4] feat: implement server versioning approach with semantic versioning support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: adam jones --- deploy/pkg/k8s/mongodb.go | 2 +- docs/faq.md | 16 ++- docs/server-json/schema.json | 3 +- docs/versioning.md | 137 ++++++++++++++++++++++++++ internal/database/memory.go | 173 +++++++++++++++++++++++++-------- tools/publisher/auth/common.go | 2 +- tools/publisher/auth/http.go | 2 +- 7 files changed, 287 insertions(+), 48 deletions(-) create mode 100644 docs/versioning.md diff --git a/deploy/pkg/k8s/mongodb.go b/deploy/pkg/k8s/mongodb.go index 87dc1692..2141ecae 100644 --- a/deploy/pkg/k8s/mongodb.go +++ b/deploy/pkg/k8s/mongodb.go @@ -138,4 +138,4 @@ func DeployMongoDB(ctx *pulumi.Context, cluster *providers.ProviderInfo, environ } return nil -} \ No newline at end of file +} diff --git a/docs/faq.md b/docs/faq.md index 0a75de68..52bc4e81 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -72,13 +72,23 @@ More can be added as the community desires; feel free to open an issue if you ar Yes, versioning is supported: - Each version gets its own immutable metadata -- Version bumps are required for updates +- Version strings must be unique for each server - Old versions remain accessible for compatibility -- The registry tracks which version is "latest" +- The registry tracks which version is "latest" based on semantic version ordering when possible ### How do I update my server metadata? -Submit a new `server.json` with an incremented version number. Once published, version metadata is immutable (similar to npm). +Submit a new `server.json` with a unique version string. Once published, version metadata is immutable (similar to npm). + +### What version format should I use? + +The registry accepts any version string up to 255 characters, but we recommend: + +- **SHOULD use semantic versioning** (e.g., "1.0.2", "2.1.0-alpha") for predictable ordering +- **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](./versioning.md) for detailed guidance. ### Can I delete/unpublish my server? diff --git a/docs/server-json/schema.json b/docs/server-json/schema.json index f139f392..7d3982fd 100644 --- a/docs/server-json/schema.json +++ b/docs/server-json/schema.json @@ -36,8 +36,9 @@ "properties": { "version": { "type": "string", + "maxLength": 255, "example": "1.0.2", - "description": "Equivalent of Implementation.version in MCP specification." + "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." } } }, diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 00000000..78bf983e --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,137 @@ +# Server Versioning Guide + +This document describes the versioning approach for MCP servers published to the registry. + +## Overview + +The MCP Registry supports flexible versioning while encouraging semantic versioning best practices. The registry attempts to parse versions as semantic versions for ordering and comparison, but falls back gracefully for non-semantic versions. + +## Version Requirements + +1. **Version String**: `version` MUST be a string up to 255 characters +2. **Uniqueness**: Each version for a given server name must be unique +3. **Immutability**: Once published, version metadata cannot be changed + +## Best Practices + +### 1. Use Semantic Versioning +Server authors SHOULD use [semantic versions](https://semver.org/) following the `MAJOR.MINOR.PATCH` format: + +```json +{ + "version_detail": { + "version": "1.2.3" + } +} +``` + +### 2. Align with Package Versions +Server authors SHOULD use versions aligned with their underlying packages to reduce confusion: + +```json +{ + "version_detail": { + "version": "1.2.3" + }, + "packages": [{ + "registry_name": "npm", + "name": "@myorg/my-server", + "version": "1.2.3" + }] +} +``` + +### 3. Multiple Registry Versions +If server authors expect to have multiple registry versions for the same package version, they SHOULD follow the semantic version spec using the prerelease label: + +```json +{ + "version_detail": { + "version": "1.2.3-1" + }, + "packages": [{ + "registry_name": "npm", + "name": "@myorg/my-server", + "version": "1.2.3" + }] +} +``` + +**Note**: According to semantic versioning, `1.2.3-1` is considered lower than `1.2.3`, so if you expect to need a `1.2.3-1`, you should publish that before `1.2.3`. + +## Version Ordering and "Latest" Determination + +### For Semantic Versions +The registry attempts to parse versions as semantic versions. If successful, it uses semantic version comparison rules to determine: +- Version ordering in lists +- Which version is marked as `is_latest` + +### For Non-Semantic Versions +If version parsing as semantic version fails: +- The registry will always mark the version as latest (overriding any previous version) +- Clients should fall back to using publish timestamp for ordering + +### Mixed Scenarios +When comparing versions where one is semantic and one is not: +- The semantic version is always considered higher +- This ensures semantic versions take precedence in ordering + +## Implementation Details + +### Registry Behavior +1. **Validation**: Versions are validated for uniqueness within a server name +2. **Parsing**: The registry attempts to parse each version as semantic version +3. **Comparison**: Uses semantic version rules when possible, falls back to timestamp +4. **Latest Flag**: The `is_latest` field is set based on the comparison results + +### Client Recommendations +Registry clients SHOULD: +1. Attempt to interpret versions as semantic versions when possible +2. Use the following ordering rules: + - If both versions are valid semver: use semver comparison + - If neither are valid semver: use publish timestamp + - If one is semver and one is not: semver version is considered higher + +## Examples + +### Valid Semantic Versions +```json +"1.0.0" // Basic semantic version +"2.1.3-alpha" // Prerelease version +"1.0.0-beta.1" // Prerelease with numeric suffix +"3.0.0-rc.2" // Release candidate +``` + +### Non-Semantic Versions (Allowed) +```json +"v1.0" // Version with prefix +"2021.03.15" // Date-based versioning +"snapshot" // Development snapshots +"latest" // Custom versioning scheme +``` + +### Alignment Examples +```json +{ + "version_detail": { + "version": "1.2.3-gke.1" + }, + "packages": [{ + "registry_name": "npm", + "name": "@myorg/k8s-server", + "version": "1.2.3" + }] +} +``` + +## Migration Path + +Existing servers with non-semantic versions will continue to work without changes. However, to benefit from proper version ordering, server maintainers are encouraged to: + +1. Adopt semantic versioning for new releases +2. Consider the ordering implications when transitioning from non-semantic to semantic versions +3. Use prerelease labels for registry-specific versioning needs + +## Future Considerations + +This versioning approach is designed to be compatible with potential future changes to the MCP specification's `Implementation.version` field. Any SHOULD requirements introduced here may be proposed as updates to the specification through the SEP (Specification Enhancement Proposal) process. \ No newline at end of file diff --git a/internal/database/memory.go b/internal/database/memory.go index 2efcb64c..d83c8db2 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -34,6 +34,44 @@ func NewMemoryDB(e map[string]*model.Server) *MemoryDB { } } +// isSemanticVersion checks if a version string follows semantic versioning format +func isSemanticVersion(version string) bool { + // Basic regex pattern for semantic versioning (simplified) + // Allows: major.minor.patch with optional prerelease (e.g., 1.0.0-alpha.1) + parts := strings.Split(version, "-") + if len(parts) > 2 { + return false + } + + // Check main version part (major.minor.patch) + versionParts := strings.Split(parts[0], ".") + if len(versionParts) != 3 { + return false + } + + for _, part := range versionParts { + if _, err := strconv.Atoi(part); err != nil { + return false + } + } + + // If there's a prerelease part, it can contain alphanumeric characters and dots + if len(parts) == 2 { + prerelease := parts[1] + if prerelease == "" { + return false + } + // Basic validation for prerelease - allow letters, numbers, dots + for _, r := range prerelease { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-') { + return false + } + } + } + + return true +} + // compareSemanticVersions compares two semantic version strings // Returns: // @@ -41,40 +79,42 @@ func NewMemoryDB(e map[string]*model.Server) *MemoryDB { // 0 if version1 == version2 // +1 if version1 > version2 func compareSemanticVersions(version1, version2 string) int { - // Simple semantic version comparison - // Assumes format: major.minor.patch + // Parse version parts (main version and prerelease) + parts1 := strings.Split(version1, "-") + parts2 := strings.Split(version2, "-") - parts1 := strings.Split(version1, ".") - parts2 := strings.Split(version2, ".") + mainParts1 := strings.Split(parts1[0], ".") + mainParts2 := strings.Split(parts2[0], ".") - // Pad with zeros if needed - maxLen := max(len(parts2), len(parts1)) + // Compare major, minor, patch + for i := 0; i < 3; i++ { + num1, _ := strconv.Atoi(mainParts1[i]) + num2, _ := strconv.Atoi(mainParts2[i]) - for len(parts1) < maxLen { - parts1 = append(parts1, "0") - } - for len(parts2) < maxLen { - parts2 = append(parts2, "0") + if num1 < num2 { + return -1 + } else if num1 > num2 { + return 1 + } } - // Compare each part - for i := 0; i < maxLen; i++ { - num1, err1 := strconv.Atoi(parts1[i]) - num2, err2 := strconv.Atoi(parts2[i]) + // If main versions are equal, compare prerelease + hasPrerelease1 := len(parts1) > 1 + hasPrerelease2 := len(parts2) > 1 - // If parsing fails, fall back to string comparison - if err1 != nil || err2 != nil { - if parts1[i] < parts2[i] { - return -1 - } else if parts1[i] > parts2[i] { - return 1 - } - continue - } + // Version without prerelease is higher than with prerelease + if !hasPrerelease1 && hasPrerelease2 { + return 1 + } + if hasPrerelease1 && !hasPrerelease2 { + return -1 + } - if num1 < num2 { + // Both have prerelease, compare lexicographically + if hasPrerelease1 && hasPrerelease2 { + if parts1[1] < parts2[1] { return -1 - } else if num1 > num2 { + } else if parts1[1] > parts2[1] { return 1 } } @@ -82,6 +122,36 @@ func compareSemanticVersions(version1, version2 string) int { return 0 } +// 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, version2 string, timestamp1, 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 +} + // List retrieves all MCPRegistry entries with optional filtering and pagination // //gocognit:ignore @@ -209,25 +279,26 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail *model.ServerDetai return ErrInvalidInput } - // check that the name and the version are unique - // Also check version ordering - don't allow publishing older versions after newer ones - var latestVersion string + // Validate version string length (max 255 characters as per schema) + if len(serverDetail.VersionDetail.Version) > 255 { + return ErrInvalidInput + } + + // Check that the name and version combination is unique + var existingEntries []*model.ServerDetail for _, entry := range db.entries { if entry.Name == serverDetail.Name { if entry.VersionDetail.Version == serverDetail.VersionDetail.Version { return ErrAlreadyExists } - - // Track the latest version for this package name - if latestVersion == "" || compareSemanticVersions(entry.VersionDetail.Version, latestVersion) > 0 { - latestVersion = entry.VersionDetail.Version - } + existingEntries = append(existingEntries, entry) } } - // If we found existing versions, check if the new version is older than the latest - if latestVersion != "" && compareSemanticVersions(serverDetail.VersionDetail.Version, latestVersion) < 0 { - return ErrInvalidVersion + // Parse the current time for timestamp-based comparisons + currentTime := time.Now() + if serverDetail.VersionDetail.ReleaseDate == "" { + serverDetail.VersionDetail.ReleaseDate = currentTime.Format(time.RFC3339) } if serverDetail.Repository.URL == "" { @@ -236,11 +307,31 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail *model.ServerDetai // Always generate a new UUID for the ID serverDetail.ID = uuid.New().String() - serverDetail.VersionDetail.IsLatest = true // Assume the new version is the latest - // Only set ReleaseDate if it's not already provided - if serverDetail.VersionDetail.ReleaseDate == "" { - serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339) + + // Determine if this version should be marked as latest based on the versioning strategy + isLatest := true + if len(existingEntries) > 0 { + // Compare with existing versions to determine if this should be latest + for _, existing := range existingEntries { + existingTime, _ := time.Parse(time.RFC3339, existing.VersionDetail.ReleaseDate) + comparison := compareVersions(serverDetail.VersionDetail.Version, existing.VersionDetail.Version, currentTime, existingTime) + if comparison < 0 { + // New version is lower than existing version + isLatest = false + break + } + } + + // If this version will be latest, mark all existing versions as not latest + if isLatest { + for _, existing := range existingEntries { + existing.VersionDetail.IsLatest = false + } + } } + + serverDetail.VersionDetail.IsLatest = isLatest + // Store a copy of the entire ServerDetail serverDetailCopy := *serverDetail db.entries[serverDetail.ID] = &serverDetailCopy diff --git a/tools/publisher/auth/common.go b/tools/publisher/auth/common.go index 8ec1e016..a1c7b7a3 100644 --- a/tools/publisher/auth/common.go +++ b/tools/publisher/auth/common.go @@ -118,4 +118,4 @@ func (c *CryptoProvider) exchangeTokenForRegistry(ctx context.Context, domain, t } return tokenResp.RegistryToken, nil -} \ No newline at end of file +} diff --git a/tools/publisher/auth/http.go b/tools/publisher/auth/http.go index 2e189edb..5a434451 100644 --- a/tools/publisher/auth/http.go +++ b/tools/publisher/auth/http.go @@ -21,4 +21,4 @@ func NewHTTPProvider(registryURL, domain, hexSeed string) Provider { // Name returns the name of this auth provider func (h *HTTPProvider) Name() string { return "http" -} \ No newline at end of file +} From dcd1fbdd1678855385661cf787c8d637fca9add6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 01:49:48 +0000 Subject: [PATCH 2/4] refactor: move version ordering logic from database to service layer - Move isSemanticVersion, compareSemanticVersions, and compareVersions functions from internal/database/memory.go to new internal/service/versioning.go - Update service layer to handle version comparison business logic - Simplify database layer to only handle persistence - All tests pass, maintaining existing functionality Co-authored-by: adam jones --- internal/database/memory.go | 152 +-------------------------- internal/service/registry_service.go | 74 ++++++++++++- internal/service/versioning.go | 125 ++++++++++++++++++++++ 3 files changed, 203 insertions(+), 148 deletions(-) create mode 100644 internal/service/versioning.go diff --git a/internal/database/memory.go b/internal/database/memory.go index d83c8db2..9f980162 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -5,8 +5,6 @@ import ( "fmt" "log" "sort" - "strconv" - "strings" "sync" "time" @@ -34,123 +32,6 @@ func NewMemoryDB(e map[string]*model.Server) *MemoryDB { } } -// isSemanticVersion checks if a version string follows semantic versioning format -func isSemanticVersion(version string) bool { - // Basic regex pattern for semantic versioning (simplified) - // Allows: major.minor.patch with optional prerelease (e.g., 1.0.0-alpha.1) - parts := strings.Split(version, "-") - if len(parts) > 2 { - return false - } - - // Check main version part (major.minor.patch) - versionParts := strings.Split(parts[0], ".") - if len(versionParts) != 3 { - return false - } - - for _, part := range versionParts { - if _, err := strconv.Atoi(part); err != nil { - return false - } - } - - // If there's a prerelease part, it can contain alphanumeric characters and dots - if len(parts) == 2 { - prerelease := parts[1] - if prerelease == "" { - return false - } - // Basic validation for prerelease - allow letters, numbers, dots - for _, r := range prerelease { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-') { - return false - } - } - } - - return true -} - -// compareSemanticVersions compares two semantic version strings -// Returns: -// -// -1 if version1 < version2 -// 0 if version1 == version2 -// +1 if version1 > version2 -func compareSemanticVersions(version1, version2 string) int { - // Parse version parts (main version and prerelease) - parts1 := strings.Split(version1, "-") - parts2 := strings.Split(version2, "-") - - mainParts1 := strings.Split(parts1[0], ".") - mainParts2 := strings.Split(parts2[0], ".") - - // Compare major, minor, patch - for i := 0; i < 3; i++ { - num1, _ := strconv.Atoi(mainParts1[i]) - num2, _ := strconv.Atoi(mainParts2[i]) - - if num1 < num2 { - return -1 - } else if num1 > num2 { - return 1 - } - } - - // If main versions are equal, compare prerelease - hasPrerelease1 := len(parts1) > 1 - hasPrerelease2 := len(parts2) > 1 - - // Version without prerelease is higher than with prerelease - if !hasPrerelease1 && hasPrerelease2 { - return 1 - } - if hasPrerelease1 && !hasPrerelease2 { - return -1 - } - - // Both have prerelease, compare lexicographically - if hasPrerelease1 && hasPrerelease2 { - if parts1[1] < parts2[1] { - return -1 - } else if parts1[1] > parts2[1] { - return 1 - } - } - - return 0 -} - -// 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, version2 string, timestamp1, 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 -} // List retrieves all MCPRegistry entries with optional filtering and pagination // @@ -285,22 +166,14 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail *model.ServerDetai } // Check that the name and version combination is unique - var existingEntries []*model.ServerDetail for _, entry := range db.entries { if entry.Name == serverDetail.Name { if entry.VersionDetail.Version == serverDetail.VersionDetail.Version { return ErrAlreadyExists } - existingEntries = append(existingEntries, entry) } } - // Parse the current time for timestamp-based comparisons - currentTime := time.Now() - if serverDetail.VersionDetail.ReleaseDate == "" { - serverDetail.VersionDetail.ReleaseDate = currentTime.Format(time.RFC3339) - } - if serverDetail.Repository.URL == "" { return ErrInvalidInput } @@ -308,30 +181,15 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail *model.ServerDetai // Always generate a new UUID for the ID serverDetail.ID = uuid.New().String() - // Determine if this version should be marked as latest based on the versioning strategy - isLatest := true - if len(existingEntries) > 0 { - // Compare with existing versions to determine if this should be latest - for _, existing := range existingEntries { - existingTime, _ := time.Parse(time.RFC3339, existing.VersionDetail.ReleaseDate) - comparison := compareVersions(serverDetail.VersionDetail.Version, existing.VersionDetail.Version, currentTime, existingTime) - if comparison < 0 { - // New version is lower than existing version - isLatest = false - break - } - } - - // If this version will be latest, mark all existing versions as not latest - if isLatest { - for _, existing := range existingEntries { - existing.VersionDetail.IsLatest = false + // If this version will be latest, mark all existing versions of the same server as not latest + if serverDetail.VersionDetail.IsLatest { + for _, entry := range db.entries { + if entry.Name == serverDetail.Name { + entry.VersionDetail.IsLatest = false } } } - serverDetail.VersionDetail.IsLatest = isLatest - // Store a copy of the entire ServerDetail serverDetailCopy := *serverDetail db.entries[serverDetail.ID] = &serverDetailCopy diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index d9798be3..c136bef7 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -84,6 +84,62 @@ func (s *registryServiceImpl) GetByID(id string) (*model.ServerDetail, error) { return serverDetail, nil } +// getVersionsByName retrieves all versions of a server by name +func (s *registryServiceImpl) getVersionsByName(ctx context.Context, name string) ([]*model.ServerDetail, error) { + // Use the database's List method to find all servers with the same name + entries, _, err := s.db.List(ctx, map[string]any{"name": name}, "", 1000) // Large limit to get all versions + if err != nil { + return nil, err + } + + var serverDetails []*model.ServerDetail + for _, entry := range entries { + serverDetail, err := s.db.GetByID(ctx, entry.ID) + if err != nil { + continue // Skip if we can't get the detail + } + serverDetails = append(serverDetails, serverDetail) + } + + return serverDetails, nil +} + +// determineIsLatest determines if a new version should be marked as latest based on the versioning strategy +func (s *registryServiceImpl) determineIsLatest(newVersion string, newTimestamp time.Time, existingVersions []*model.ServerDetail) bool { + if len(existingVersions) == 0 { + return true + } + + for _, existing := range existingVersions { + existingTime, _ := time.Parse(time.RFC3339, existing.VersionDetail.ReleaseDate) + comparison := CompareVersions(newVersion, existing.VersionDetail.Version, newTimestamp, existingTime) + if comparison < 0 { + // New version is lower than existing version + return false + } + } + + return true +} + +// markOtherVersionsAsNotLatest marks all other versions of the same server as not latest +func (s *registryServiceImpl) markOtherVersionsAsNotLatest(ctx context.Context, serverName string) error { + existingVersions, err := s.getVersionsByName(ctx, serverName) + if err != nil { + return err + } + + for _, existing := range existingVersions { + if existing.VersionDetail.IsLatest { + existing.VersionDetail.IsLatest = false + // We need to update this in the database, but the current interface doesn't support updates + // For now, this will be handled in the database layer during Publish + } + } + + return nil +} + // Publish adds a new server detail to the registry func (s *registryServiceImpl) Publish(serverDetail *model.ServerDetail) error { // Create a timeout context for the database operation @@ -94,7 +150,23 @@ func (s *registryServiceImpl) Publish(serverDetail *model.ServerDetail) error { return database.ErrInvalidInput } - err := s.db.Publish(ctx, serverDetail) + // Get existing versions of this server to determine if this should be latest + existingVersions, err := s.getVersionsByName(ctx, serverDetail.Name) + if err != nil && err != database.ErrNotFound { + return err + } + + // Parse the current time for timestamp-based comparisons + currentTime := time.Now() + if serverDetail.VersionDetail.ReleaseDate == "" { + serverDetail.VersionDetail.ReleaseDate = currentTime.Format(time.RFC3339) + } + + // Determine if this version should be marked as latest + isLatest := s.determineIsLatest(serverDetail.VersionDetail.Version, currentTime, existingVersions) + serverDetail.VersionDetail.IsLatest = isLatest + + err = s.db.Publish(ctx, serverDetail) if err != nil { return err } diff --git a/internal/service/versioning.go b/internal/service/versioning.go new file mode 100644 index 00000000..8eee7e27 --- /dev/null +++ b/internal/service/versioning.go @@ -0,0 +1,125 @@ +package service + +import ( + "strconv" + "strings" + "time" +) + +// IsSemanticVersion checks if a version string follows semantic versioning format +func IsSemanticVersion(version string) bool { + // Basic regex pattern for semantic versioning (simplified) + // Allows: major.minor.patch with optional prerelease (e.g., 1.0.0-alpha.1) + parts := strings.Split(version, "-") + if len(parts) > 2 { + return false + } + + // Check main version part (major.minor.patch) + versionParts := strings.Split(parts[0], ".") + if len(versionParts) != 3 { + return false + } + + for _, part := range versionParts { + if _, err := strconv.Atoi(part); err != nil { + return false + } + } + + // If there's a prerelease part, it can contain alphanumeric characters and dots + if len(parts) == 2 { + prerelease := parts[1] + if prerelease == "" { + return false + } + // Basic validation for prerelease - allow letters, numbers, dots + for _, r := range prerelease { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-') { + return false + } + } + } + + return true +} + +// CompareSemanticVersions compares two semantic version strings +// Returns: +// +// -1 if version1 < version2 +// 0 if version1 == version2 +// +1 if version1 > version2 +func CompareSemanticVersions(version1, version2 string) int { + // Parse version parts (main version and prerelease) + parts1 := strings.Split(version1, "-") + parts2 := strings.Split(version2, "-") + + mainParts1 := strings.Split(parts1[0], ".") + mainParts2 := strings.Split(parts2[0], ".") + + // Compare major, minor, patch + for i := 0; i < 3; i++ { + num1, _ := strconv.Atoi(mainParts1[i]) + num2, _ := strconv.Atoi(mainParts2[i]) + + if num1 < num2 { + return -1 + } else if num1 > num2 { + return 1 + } + } + + // If main versions are equal, compare prerelease + hasPrerelease1 := len(parts1) > 1 + hasPrerelease2 := len(parts2) > 1 + + // Version without prerelease is higher than with prerelease + if !hasPrerelease1 && hasPrerelease2 { + return 1 + } + if hasPrerelease1 && !hasPrerelease2 { + return -1 + } + + // Both have prerelease, compare lexicographically + if hasPrerelease1 && hasPrerelease2 { + if parts1[1] < parts2[1] { + return -1 + } else if parts1[1] > parts2[1] { + return 1 + } + } + + return 0 +} + +// 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, version2 string, timestamp1, 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 +} \ No newline at end of file From 4eb163529ef90ed90d9807d99856f5c545889604 Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Sat, 23 Aug 2025 03:41:27 +0000 Subject: [PATCH 3/4] Address comments :house: Remote-Dev: homespace --- docs/versioning.md | 11 +- internal/service/registry_service.go | 21 +--- internal/service/versioning.go | 2 +- internal/service/versioning_test.go | 153 +++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 27 deletions(-) create mode 100644 internal/service/versioning_test.go diff --git a/docs/versioning.md b/docs/versioning.md index 78bf983e..7462cd5c 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -71,10 +71,7 @@ If version parsing as semantic version fails: - The registry will always mark the version as latest (overriding any previous version) - Clients should fall back to using publish timestamp for ordering -### Mixed Scenarios -When comparing versions where one is semantic and one is not: -- The semantic version is always considered higher -- This ensures semantic versions take precedence in ordering +**Important Note**: This behavior means that for servers with mixed semantic and non-semantic versions, the `is_latest` flag may not align with the total ordering. A non-semantic version published after semantic versions will be marked as latest, even if semantic versions are considered "higher" in the ordering. ## Implementation Details @@ -95,7 +92,7 @@ Registry clients SHOULD: ## Examples ### Valid Semantic Versions -```json +```javascript "1.0.0" // Basic semantic version "2.1.3-alpha" // Prerelease version "1.0.0-beta.1" // Prerelease with numeric suffix @@ -103,7 +100,7 @@ Registry clients SHOULD: ``` ### Non-Semantic Versions (Allowed) -```json +```javascript "v1.0" // Version with prefix "2021.03.15" // Date-based versioning "snapshot" // Development snapshots @@ -114,7 +111,7 @@ Registry clients SHOULD: ```json { "version_detail": { - "version": "1.2.3-gke.1" + "version": "1.2.3-1" }, "packages": [{ "registry_name": "npm", diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index c136bef7..ce7da3d8 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "time" "github.com/modelcontextprotocol/registry/internal/database" @@ -122,24 +123,6 @@ func (s *registryServiceImpl) determineIsLatest(newVersion string, newTimestamp return true } -// markOtherVersionsAsNotLatest marks all other versions of the same server as not latest -func (s *registryServiceImpl) markOtherVersionsAsNotLatest(ctx context.Context, serverName string) error { - existingVersions, err := s.getVersionsByName(ctx, serverName) - if err != nil { - return err - } - - for _, existing := range existingVersions { - if existing.VersionDetail.IsLatest { - existing.VersionDetail.IsLatest = false - // We need to update this in the database, but the current interface doesn't support updates - // For now, this will be handled in the database layer during Publish - } - } - - return nil -} - // Publish adds a new server detail to the registry func (s *registryServiceImpl) Publish(serverDetail *model.ServerDetail) error { // Create a timeout context for the database operation @@ -152,7 +135,7 @@ func (s *registryServiceImpl) Publish(serverDetail *model.ServerDetail) error { // Get existing versions of this server to determine if this should be latest existingVersions, err := s.getVersionsByName(ctx, serverDetail.Name) - if err != nil && err != database.ErrNotFound { + if err != nil && !errors.Is(err, database.ErrNotFound) { return err } diff --git a/internal/service/versioning.go b/internal/service/versioning.go index 8eee7e27..482d0b1a 100644 --- a/internal/service/versioning.go +++ b/internal/service/versioning.go @@ -35,7 +35,7 @@ func IsSemanticVersion(version string) bool { } // Basic validation for prerelease - allow letters, numbers, dots for _, r := range prerelease { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-') { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '.' && r != '-' { return false } } diff --git a/internal/service/versioning_test.go b/internal/service/versioning_test.go new file mode 100644 index 00000000..1ff6f284 --- /dev/null +++ b/internal/service/versioning_test.go @@ -0,0 +1,153 @@ +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}, + {"with hyphen in prerelease", "1.0.0-pre-release", false}, // Multiple hyphens not allowed in current implementation + + // Invalid semantic versions + {"empty string", "", false}, + {"single number", "1", false}, + {"two parts only", "1.0", false}, + {"four parts", "1.0.0.0", false}, + {"with v prefix", "v1.0.0", false}, + {"date format", "2021.03.15", true}, // Technically valid semver format (3 numeric parts) + {"date format with leading zeros", "2021.03.05", true}, // Also valid + {"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}, + {"multiple hyphens", "1.0.0-alpha-beta", false}, + {"special chars in prerelease", "1.0.0-alpha@1", false}, + {"snapshot", "snapshot", false}, + {"latest", "latest", 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}, + + // 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) { + if got := service.CompareSemanticVersions(tt.version1, tt.version2); got != tt.want { + t.Errorf("CompareSemanticVersions(%q, %q) = %v, want %v", tt.version1, tt.version2, got, tt.want) + } + }) + } +} + +func TestCompareVersions(t *testing.T) { + now := time.Now() + earlier := now.Add(-1 * time.Hour) + later := now.Add(1 * 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 - use timestamps + {"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", "v1.0", "v2.0", earlier, later, -1}, + + // Mixed: one semver, one not - semver always wins + {"semver vs non-semver", "1.0.0", "v2.0.0", now, now, 1}, + {"non-semver vs semver", "v2.0.0", "1.0.0", now, now, -1}, + {"semver vs snapshot", "0.0.1", "snapshot", earlier, later, 1}, + {"latest vs semver", "latest", "0.0.1", later, earlier, -1}, + {"semver prerelease vs non-semver", "1.0.0-alpha", "v5.0.0", 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", "", "", earlier, later, -1}, + } + + 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) + } + }) + } +} \ No newline at end of file From 15574c96b8cd364aca3cef4b401da4b788a74d02 Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Sat, 23 Aug 2025 03:43:00 +0000 Subject: [PATCH 4/4] fix: clarify version ordering rules for registry clients :house: Remote-Dev: homespace --- docs/versioning.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/versioning.md b/docs/versioning.md index 7462cd5c..8a4b670f 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -85,6 +85,7 @@ If version parsing as semantic version fails: Registry clients SHOULD: 1. Attempt to interpret versions as semantic versions when possible 2. Use the following ordering rules: + - If one version is marked as is_latest: it is later - If both versions are valid semver: use semver comparison - If neither are valid semver: use publish timestamp - If one is semver and one is not: semver version is considered higher