Skip to content

feat: implement server versioning approach with semantic versioning support #296

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deploy/pkg/k8s/mongodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,4 @@ func DeployMongoDB(ctx *pulumi.Context, cluster *providers.ProviderInfo, environ
}

return nil
}
}
16 changes: 13 additions & 3 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
3 changes: 2 additions & 1 deletion docs/server-json/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
137 changes: 137 additions & 0 deletions docs/versioning.md
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels reasonable in isolation. but actually means that version's total ordering is inconsistent with is_latest, for packages with a mix of versions.

Not sure what is best to do here. Maybe this is fine. Maybe we should only update is_latest if it's now the latest in the total ordering spec below, idk.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this behavior is necessary because the publisher does not indicate whether they are actually using semantic versioning. For example, if we said "semantic versions always take precedence", and a publisher is using non-semantic version numbers that sometimes appear to be semantic, then they are going to have a bad time. 😆

Unfortunately, as you noted, this means there is no natural sort order. Instead, we have to maintain an insertion order based on this logic.

- 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.
81 changes: 15 additions & 66 deletions internal/database/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import (
"fmt"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -34,53 +32,6 @@ func NewMemoryDB(e map[string]*model.Server) *MemoryDB {
}
}

// 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 {
// Simple semantic version comparison
// Assumes format: major.minor.patch

parts1 := strings.Split(version1, ".")
parts2 := strings.Split(version2, ".")

// Pad with zeros if needed
maxLen := max(len(parts2), len(parts1))

for len(parts1) < maxLen {
parts1 = append(parts1, "0")
}
for len(parts2) < maxLen {
parts2 = append(parts2, "0")
}

// Compare each part
for i := 0; i < maxLen; i++ {
num1, err1 := strconv.Atoi(parts1[i])
num2, err2 := strconv.Atoi(parts2[i])

// 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
}

if num1 < num2 {
return -1
} else if num1 > num2 {
return 1
}
}

return 0
}

// List retrieves all MCPRegistry entries with optional filtering and pagination
//
Expand Down Expand Up @@ -209,38 +160,36 @@ 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
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
}
}
}

// 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
}

if serverDetail.Repository.URL == "" {
return ErrInvalidInput
}

// 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)

// 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
}
}
}

// Store a copy of the entire ServerDetail
serverDetailCopy := *serverDetail
db.entries[serverDetail.ID] = &serverDetailCopy
Expand Down
74 changes: 73 additions & 1 deletion internal/service/registry_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Loading