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>
158 lines
4.8 KiB
Go
158 lines
4.8 KiB
Go
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
|
|
}
|
|
|
|
// NewRegistryServiceWithDB creates a new registry service with the provided database
|
|
//
|
|
//nolint:ireturn // Factory function intentionally returns interface for dependency injection
|
|
func NewRegistryServiceWithDB(db database.Database) RegistryService {
|
|
return ®istryServiceImpl{
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// List returns registry entries with cursor-based pagination in extension wrapper format
|
|
func (s *registryServiceImpl) List(cursor string, limit int) ([]model.ServerResponse, string, error) {
|
|
// Create a timeout context for the database operation
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// If limit is not set or negative, use a default limit
|
|
if limit <= 0 {
|
|
limit = 30
|
|
}
|
|
|
|
// Use the database's List method with pagination
|
|
serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
// Convert ServerRecord to ServerResponse format
|
|
result := make([]model.ServerResponse, len(serverRecords))
|
|
for i, record := range serverRecords {
|
|
result[i] = record.ToServerResponse()
|
|
}
|
|
|
|
return result, nextCursor, nil
|
|
}
|
|
|
|
// GetByID retrieves a specific server by its registry metadata ID in extension wrapper format
|
|
func (s *registryServiceImpl) GetByID(id string) (*model.ServerResponse, error) {
|
|
// Create a timeout context for the database operation
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Use the database's GetByID method to retrieve the server record
|
|
serverRecord, err := s.db.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Convert ServerRecord to ServerResponse format
|
|
response := serverRecord.ToServerResponse()
|
|
return &response, nil
|
|
}
|
|
|
|
// Publish publishes a server with separated extensions
|
|
func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerResponse, error) {
|
|
// Create a timeout context for the database operation
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Validate the request
|
|
if err := model.ValidatePublisherExtensions(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate server name exists
|
|
if _, err := model.ParseServerName(req.Server); err != nil {
|
|
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)
|
|
|
|
// 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
|
|
}
|
|
|
|
// Convert ServerRecord to ServerResponse format
|
|
response := serverRecord.ToServerResponse()
|
|
return &response, nil
|
|
}
|