This is a first PR for #159 - adding some validation scripts. I want to get to a place where we are confident that making schema changes is in sync with the rest of our code. Step one is making sure the schemas are in sync with our examples. Step two will be making sure the schemas are in sync with our actual Registry implementation code. This is just for the JSON right now; if we're feeling good about this approach I'll do an analogous change for the OpenAPI schemas as well. I'll also follow with adding usage of these scripts to our CI pipeline so we have automated checks on them with every change. ## Summary - Added CLI validation tools for JSON schemas and examples in `docs/server-json/` - Fixed some minor validation errors found in `examples.md` - Ensures schema consistency and example validity in CI/CD pipelines ## Changes ### New Validation Tools 1. **`tools/validate-schemas`** - Validates that `schema.json` and `registry-schema.json` are valid JSON Schema documents 2. **`tools/validate-examples`** - Validates all JSON examples in `examples.md` against both schemas ### Key Features - ✅ Validates JSON schema syntax and structure - ✅ Validates all examples against both base and registry schemas - ✅ Tracks validation counts to prevent silent failures - ✅ Proper exit codes for CI integration - ✅ Clear error messages for debugging ### Fixes to examples.md - Added missing `repository.id` field in NuGet example - Changed `repository.source` from "gitlab" to "github" in Docker example ### Usage ```bash # From repository root ./tools/validate-schemas.sh ./tools/validate-examples.sh ``` ## Testing I ran these scripts locally with minor (breaking) changes, which they each correctly reported. As submitted, they are both passing. 🤖 Co-authored with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Charles Lowell <10964656+chlowell@users.noreply.github.com>
308 lines
7.2 KiB
Go
308 lines
7.2 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/modelcontextprotocol/registry/internal/model"
|
|
)
|
|
|
|
// MemoryDB is an in-memory implementation of the Database interface
|
|
type MemoryDB struct {
|
|
entries map[string]*model.ServerDetail
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewMemoryDB creates a new instance of the in-memory database
|
|
func NewMemoryDB(e map[string]*model.Server) *MemoryDB {
|
|
// Convert Server entries to ServerDetail entries
|
|
serverDetails := make(map[string]*model.ServerDetail)
|
|
for k, v := range e {
|
|
serverDetails[k] = &model.ServerDetail{
|
|
Server: *v,
|
|
}
|
|
}
|
|
return &MemoryDB{
|
|
entries: serverDetails,
|
|
}
|
|
}
|
|
|
|
// 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 := len(parts1)
|
|
if len(parts2) > maxLen {
|
|
maxLen = len(parts2)
|
|
}
|
|
|
|
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
|
|
//
|
|
//gocognit:ignore
|
|
func (db *MemoryDB) List(
|
|
ctx context.Context,
|
|
filter map[string]any,
|
|
cursor string,
|
|
limit int,
|
|
) ([]*model.Server, string, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, "", ctx.Err()
|
|
}
|
|
|
|
if limit <= 0 {
|
|
limit = 10 // Default limit
|
|
}
|
|
|
|
db.mu.RLock()
|
|
defer db.mu.RUnlock()
|
|
|
|
// Convert all entries to a slice for pagination
|
|
var allEntries []*model.Server
|
|
for _, entry := range db.entries {
|
|
serverCopy := entry.Server
|
|
allEntries = append(allEntries, &serverCopy)
|
|
}
|
|
|
|
// Simple filtering implementation
|
|
var filteredEntries []*model.Server
|
|
for _, entry := range allEntries {
|
|
include := true
|
|
|
|
// Apply filters if any
|
|
for key, value := range filter {
|
|
switch key {
|
|
case "name":
|
|
if entry.Name != value.(string) {
|
|
include = false
|
|
}
|
|
case "repoUrl":
|
|
if entry.Repository.URL != value.(string) {
|
|
include = false
|
|
}
|
|
case "serverDetail.id":
|
|
if entry.ID != value.(string) {
|
|
include = false
|
|
}
|
|
case "version":
|
|
if entry.VersionDetail.Version != value.(string) {
|
|
include = false
|
|
}
|
|
// Add more filter options as needed
|
|
}
|
|
}
|
|
|
|
if include {
|
|
filteredEntries = append(filteredEntries, entry)
|
|
}
|
|
}
|
|
|
|
// Find starting point for cursor-based pagination
|
|
startIdx := 0
|
|
if cursor != "" {
|
|
for i, entry := range filteredEntries {
|
|
if entry.ID == cursor {
|
|
startIdx = i + 1 // Start after the cursor
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort filteredEntries by ID for consistent pagination
|
|
sort.Slice(filteredEntries, func(i, j int) bool {
|
|
return filteredEntries[i].ID < filteredEntries[j].ID
|
|
})
|
|
|
|
// Apply pagination
|
|
endIdx := startIdx + limit
|
|
if endIdx > len(filteredEntries) {
|
|
endIdx = len(filteredEntries)
|
|
}
|
|
|
|
var result []*model.Server
|
|
if startIdx < len(filteredEntries) {
|
|
result = filteredEntries[startIdx:endIdx]
|
|
} else {
|
|
result = []*model.Server{}
|
|
}
|
|
|
|
// Determine next cursor
|
|
nextCursor := ""
|
|
if endIdx < len(filteredEntries) {
|
|
nextCursor = filteredEntries[endIdx-1].ID
|
|
}
|
|
|
|
return result, nextCursor, nil
|
|
}
|
|
|
|
// GetByID retrieves a single ServerDetail by its ID
|
|
func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerDetail, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
db.mu.RLock()
|
|
defer db.mu.RUnlock()
|
|
|
|
if entry, exists := db.entries[id]; exists {
|
|
// Return a copy of the ServerDetail
|
|
serverDetailCopy := *entry
|
|
return &serverDetailCopy, nil
|
|
}
|
|
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
// Publish adds a new ServerDetail to the database
|
|
func (db *MemoryDB) Publish(ctx context.Context, serverDetail *model.ServerDetail) error {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
|
|
// check for name
|
|
if serverDetail.Name == "" {
|
|
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
|
|
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
|
|
}
|
|
|
|
// Generate a new ID for the server detail
|
|
serverDetail.ID = uuid.New().String()
|
|
serverDetail.VersionDetail.IsLatest = true // Assume the new version is the latest
|
|
serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339)
|
|
// Store a copy of the entire ServerDetail
|
|
serverDetailCopy := *serverDetail
|
|
db.entries[serverDetail.ID] = &serverDetailCopy
|
|
|
|
return nil
|
|
}
|
|
|
|
// ImportSeed imports initial data from a seed file into memory database
|
|
func (db *MemoryDB) ImportSeed(ctx context.Context, seedFilePath string) error {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
// Read the seed file
|
|
seedData, err := ReadSeedFile(seedFilePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read seed file: %w", err)
|
|
}
|
|
|
|
log.Printf("Importing %d servers into memory database", len(seedData))
|
|
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
|
|
for i, server := range seedData {
|
|
if server.ID == "" || server.Name == "" {
|
|
log.Printf("Skipping server %d: ID or Name is empty", i+1)
|
|
continue
|
|
}
|
|
|
|
// Set default version information if missing
|
|
if server.VersionDetail.Version == "" {
|
|
server.VersionDetail.Version = "0.0.1-seed"
|
|
server.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339)
|
|
server.VersionDetail.IsLatest = true
|
|
}
|
|
|
|
// Store a copy of the server detail
|
|
serverDetailCopy := server
|
|
db.entries[server.ID] = &serverDetailCopy
|
|
|
|
log.Printf("[%d/%d] Imported server: %s", i+1, len(seedData), server.Name)
|
|
}
|
|
|
|
log.Println("Memory database import completed successfully")
|
|
return nil
|
|
}
|
|
|
|
// Close closes the database connection
|
|
// For an in-memory database, this is a no-op
|
|
func (db *MemoryDB) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// Connection returns information about the database connection
|
|
func (db *MemoryDB) Connection() *ConnectionInfo {
|
|
return &ConnectionInfo{
|
|
Type: ConnectionTypeMemory,
|
|
IsConnected: true, // Memory DB is always connected
|
|
Raw: db.entries,
|
|
}
|
|
}
|