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
17 changed files with 561 additions and 164 deletions
16
docs/faq.md
16
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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
135
docs/versioning.md
Normal file
135
docs/versioning.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# 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
|
||||
|
||||
**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
|
||||
|
||||
### 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 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
|
||||
|
||||
## Examples
|
||||
|
||||
### Valid Semantic Versions
|
||||
```javascript
|
||||
"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)
|
||||
```javascript
|
||||
"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-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.
|
||||
1
go.mod
1
go.mod
|
|
@ -38,6 +38,7 @@ require (
|
|||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -83,6 +83,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ import (
|
|||
|
||||
// Common database errors
|
||||
var (
|
||||
ErrNotFound = errors.New("record not found")
|
||||
ErrAlreadyExists = errors.New("record already exists")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrDatabase = errors.New("database error")
|
||||
ErrInvalidVersion = errors.New("invalid version: cannot publish older version after newer version")
|
||||
ErrNotFound = errors.New("record not found")
|
||||
ErrAlreadyExists = errors.New("record already exists")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrDatabase = errors.New("database error")
|
||||
ErrInvalidVersion = errors.New("invalid version: cannot publish older version after newer version")
|
||||
ErrMaxServersReached = errors.New("maximum number of versions for this server reached (10000): please reach out at https://github.com/modelcontextprotocol/registry to explain your use case")
|
||||
)
|
||||
|
||||
// Database defines the interface for database operations with extension wrapper architecture
|
||||
|
|
@ -23,7 +24,10 @@ type Database interface {
|
|||
// GetByID retrieves a single ServerRecord by its ID
|
||||
GetByID(ctx context.Context, id string) (*model.ServerRecord, error)
|
||||
// Publish adds a new server to the database with separated server.json and extensions
|
||||
Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error)
|
||||
// The registryMetadata contains metadata determined by the service layer (e.g., is_latest, timestamps)
|
||||
Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error)
|
||||
// UpdateLatestFlag updates the is_latest flag for a specific server record
|
||||
UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error
|
||||
// ImportSeed imports initial data from a seed file
|
||||
ImportSeed(ctx context.Context, seedFilePath string) error
|
||||
// Close closes the database connection
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
|
|
@ -44,53 +41,6 @@ func NewMemoryDB(e map[string]*model.ServerDetail) *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 ServerRecord entries with optional filtering and pagination
|
||||
func (db *MemoryDB) List(
|
||||
|
|
@ -202,7 +152,7 @@ func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerRecord
|
|||
}
|
||||
|
||||
// Publish adds a new server to the database with separated server.json and extensions
|
||||
func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) {
|
||||
func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
|
@ -221,38 +171,11 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail
|
|||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
|
||||
// Check for existing entry with same name and compare versions
|
||||
var existingRecord *model.ServerRecord
|
||||
for _, entry := range db.entries {
|
||||
if entry.RegistryMetadata.IsLatest && entry.ServerJSON.Name == name {
|
||||
existingRecord = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Version comparison
|
||||
if existingRecord != nil {
|
||||
existingVersion := existingRecord.ServerJSON.VersionDetail.Version
|
||||
if compareSemanticVersions(version, existingVersion) <= 0 {
|
||||
return nil, fmt.Errorf("version must be greater than existing version %s", existingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate repository URL
|
||||
if serverDetail.Repository.URL == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
|
||||
// Create new registry metadata
|
||||
now := time.Now()
|
||||
registryMetadata := model.RegistryMetadata{
|
||||
ID: uuid.New().String(),
|
||||
PublishedAt: now,
|
||||
UpdatedAt: now,
|
||||
IsLatest: true,
|
||||
ReleaseDate: now.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Create server record
|
||||
record := &model.ServerRecord{
|
||||
ServerJSON: serverDetail,
|
||||
|
|
@ -260,11 +183,6 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail
|
|||
PublisherExtensions: publisherExtensions,
|
||||
}
|
||||
|
||||
// Mark existing record as not latest
|
||||
if existingRecord != nil {
|
||||
existingRecord.RegistryMetadata.IsLatest = false
|
||||
}
|
||||
|
||||
// Store the record using registry metadata ID
|
||||
db.entries[registryMetadata.ID] = record
|
||||
|
||||
|
|
@ -298,6 +216,24 @@ func (db *MemoryDB) ImportSeed(ctx context.Context, seedFilePath string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// UpdateLatestFlag updates the is_latest flag for a specific server record
|
||||
func (db *MemoryDB) UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
|
||||
if entry, exists := db.entries[id]; exists {
|
||||
entry.RegistryMetadata.IsLatest = isLatest
|
||||
entry.RegistryMetadata.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
// For an in-memory database, this is a no-op
|
||||
func (db *MemoryDB) Close() error {
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerReco
|
|||
}
|
||||
|
||||
// Publish adds a new server to the database with separated server.json and extensions
|
||||
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) {
|
||||
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
|
@ -288,23 +288,6 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
|
|||
}
|
||||
}()
|
||||
|
||||
// Check if there's an existing latest version for this server
|
||||
var existingVersion string
|
||||
checkQuery := `
|
||||
SELECT s.version
|
||||
FROM servers s
|
||||
JOIN server_extensions se ON s.id = se.server_id
|
||||
WHERE s.name = $1 AND se.is_latest = true
|
||||
`
|
||||
err = tx.QueryRow(ctx, checkQuery, serverDetail.Name).Scan(&existingVersion)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("failed to check existing version: %w", err)
|
||||
}
|
||||
|
||||
// Validate version ordering
|
||||
if existingVersion != "" && serverDetail.VersionDetail.Version <= existingVersion {
|
||||
return nil, ErrInvalidVersion
|
||||
}
|
||||
|
||||
// Prepare JSON data for server table
|
||||
repositoryJSON, err := json.Marshal(serverDetail.Repository)
|
||||
|
|
@ -327,26 +310,9 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
|
|||
return nil, fmt.Errorf("failed to marshal publisher extensions: %w", err)
|
||||
}
|
||||
|
||||
// Generate server ID and create registry metadata
|
||||
serverID := uuid.New().String()
|
||||
registryID := uuid.New().String()
|
||||
now := time.Now()
|
||||
// Use the same ID for both server and server_extensions records (1:1 relationship)
|
||||
serverID := registryMetadata.ID
|
||||
|
||||
// Update existing latest version to not be latest
|
||||
if existingVersion != "" {
|
||||
updateQuery := `
|
||||
UPDATE server_extensions
|
||||
SET is_latest = false
|
||||
WHERE server_id IN (
|
||||
SELECT s.id FROM servers s WHERE s.name = $1 AND server_extensions.server_id = s.id
|
||||
)
|
||||
AND is_latest = true
|
||||
`
|
||||
_, err = tx.Exec(ctx, updateQuery, serverDetail.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update existing latest version: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new server record
|
||||
insertServerQuery := `
|
||||
|
|
@ -373,12 +339,12 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
|
|||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`
|
||||
_, err = tx.Exec(ctx, insertExtensionsQuery,
|
||||
registryID,
|
||||
registryMetadata.ID,
|
||||
serverID,
|
||||
now,
|
||||
now,
|
||||
true, // is_latest
|
||||
now, // release_date
|
||||
registryMetadata.PublishedAt,
|
||||
registryMetadata.UpdatedAt,
|
||||
registryMetadata.IsLatest,
|
||||
registryMetadata.PublishedAt, // release_date
|
||||
publisherExtensionsJSON,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -392,14 +358,8 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
|
|||
|
||||
// Create and return the ServerRecord
|
||||
record := &model.ServerRecord{
|
||||
ServerJSON: serverDetail,
|
||||
RegistryMetadata: model.RegistryMetadata{
|
||||
ID: registryID,
|
||||
PublishedAt: now,
|
||||
UpdatedAt: now,
|
||||
IsLatest: true,
|
||||
ReleaseDate: now.Format(time.RFC3339),
|
||||
},
|
||||
ServerJSON: serverDetail,
|
||||
RegistryMetadata: registryMetadata,
|
||||
PublisherExtensions: publisherExtensions,
|
||||
}
|
||||
|
||||
|
|
@ -447,17 +407,15 @@ func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error
|
|||
|
||||
// publishWithTransaction handles publishing within an existing transaction, optionally with predefined metadata
|
||||
func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, existingMetadata *model.RegistryMetadata) error {
|
||||
var serverID string
|
||||
var extensionID string
|
||||
|
||||
// Use the same ID for both server and server_extensions (1:1 relationship)
|
||||
var id string
|
||||
if existingMetadata != nil && existingMetadata.ID != "" {
|
||||
// Use predefined IDs from seed data
|
||||
serverID = existingMetadata.ID
|
||||
extensionID = existingMetadata.ID // In seed data, these are the same
|
||||
// Use predefined ID from seed data
|
||||
id = existingMetadata.ID
|
||||
} else {
|
||||
// Generate new UUIDs for normal publishing
|
||||
serverID = uuid.New().String()
|
||||
extensionID = uuid.New().String()
|
||||
// This shouldn't happen as service layer should always provide an ID
|
||||
// But keeping as fallback for safety
|
||||
id = uuid.New().String()
|
||||
}
|
||||
|
||||
// Marshal packages and remotes to JSONB
|
||||
|
|
@ -497,7 +455,7 @@ func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, ser
|
|||
|
||||
var returnedServerID string
|
||||
err = tx.QueryRow(ctx, serverQuery,
|
||||
serverID,
|
||||
id,
|
||||
serverDetail.Name,
|
||||
serverDetail.Description,
|
||||
string(serverDetail.Status),
|
||||
|
|
@ -532,7 +490,7 @@ func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, ser
|
|||
}
|
||||
|
||||
_, err = tx.Exec(ctx, extensionQuery,
|
||||
extensionID,
|
||||
id,
|
||||
returnedServerID,
|
||||
publishedAt,
|
||||
releaseDate,
|
||||
|
|
@ -545,6 +503,30 @@ func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, ser
|
|||
return nil
|
||||
}
|
||||
|
||||
// UpdateLatestFlag updates the is_latest flag for a specific server record
|
||||
func (db *PostgreSQL) UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE server_extensions
|
||||
SET is_latest = $1, updated_at = $2
|
||||
WHERE id = $3
|
||||
`
|
||||
|
||||
result, err := db.conn.Exec(ctx, query, isLatest, time.Now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update latest flag: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (db *PostgreSQL) Close() error {
|
||||
return db.conn.Close(context.Background())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -118,4 +118,4 @@ func (c *CryptoProvider) exchangeTokenForRegistry(ctx context.Context, domain, t
|
|||
}
|
||||
|
||||
return tokenResp.RegistryToken, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ type DNSProvider struct {
|
|||
}
|
||||
|
||||
// NewDNSProvider creates a new DNS-based auth provider
|
||||
//nolint:ireturn // Factory function returns interface by design
|
||||
//
|
||||
//nolint:ireturn // Factory function intentionally returns interface for dependency injection
|
||||
func NewDNSProvider(registryURL, domain, hexSeed string) Provider {
|
||||
return &DNSProvider{
|
||||
CryptoProvider: &CryptoProvider{
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ type ServerHealthResponse struct {
|
|||
}
|
||||
|
||||
// NewGitHubATProvider creates a new GitHub OAuth provider
|
||||
//nolint:ireturn // Factory function returns interface by design
|
||||
//
|
||||
//nolint:ireturn // Factory function intentionally returns interface for dependency injection
|
||||
func NewGitHubATProvider(forceLogin bool, registryURL string) Provider {
|
||||
return &GitHubATProvider{
|
||||
forceLogin: forceLogin,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ type GitHubOIDCProvider struct {
|
|||
registryURL string
|
||||
}
|
||||
|
||||
//nolint:ireturn // Factory function returns interface by design
|
||||
// NewGitHubOIDCProvider creates a new GitHub OIDC provider
|
||||
//
|
||||
//nolint:ireturn // Factory function intentionally returns interface for dependency injection
|
||||
func NewGitHubOIDCProvider(registryURL string) Provider {
|
||||
return &GitHubOIDCProvider{
|
||||
registryURL: registryURL,
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ func NewHTTPProvider(registryURL, domain, hexSeed string) Provider {
|
|||
// Name returns the name of this auth provider
|
||||
func (h *HTTPProvider) Name() string {
|
||||
return "http"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue