diff --git a/internal/api/handlers/v0/publish.go b/internal/api/handlers/v0/publish.go new file mode 100644 index 0000000..8bfa70b --- /dev/null +++ b/internal/api/handlers/v0/publish.go @@ -0,0 +1,64 @@ +// Package v0 contains API handlers for version 0 of the API +package v0 + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/modelcontextprotocol/registry/internal/model" + "github.com/modelcontextprotocol/registry/internal/service" +) + +// PublishHandler handles requests to publish new server details to the registry +func PublishHandler(registry service.RegistryService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Only allow POST method + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Read the request body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error reading request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Parse request body into ServerDetail struct + var serverDetail model.ServerDetail + err = json.Unmarshal(body, &serverDetail) + if err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + // Validate required fields + if serverDetail.Name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + + // version is required + if serverDetail.VersionDetail.Version == "" { + http.Error(w, "Version is required", http.StatusBadRequest) + return + } + + // Call the publish method on the registry service + err = registry.Publish(&serverDetail) + if err != nil { + http.Error(w, "Failed to publish server details: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Server publication successful", + "id": serverDetail.ID, + }) + } +} diff --git a/internal/api/router/v0.go b/internal/api/router/v0.go index a76c169..3099c15 100644 --- a/internal/api/router/v0.go +++ b/internal/api/router/v0.go @@ -16,6 +16,7 @@ func RegisterV0Routes(mux *http.ServeMux, cfg *config.Config, registry service.R mux.HandleFunc("/v0/servers", v0.ServersHandler(registry)) mux.HandleFunc("/v0/servers/{id}", v0.ServersDetailHandler(registry)) mux.HandleFunc("/v0/ping", v0.PingHandler(cfg)) + mux.HandleFunc("/v0/publish", v0.PublishHandler(registry)) // Register Swagger UI routes mux.HandleFunc("/v0/swagger/", v0.SwaggerHandler()) diff --git a/internal/database/database.go b/internal/database/database.go index 932335f..c21a5ed 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -21,6 +21,8 @@ type Database interface { List(ctx context.Context, filter map[string]interface{}, cursor string, limit int) ([]*model.Entry, string, error) // GetByID retrieves a single ServerDetail by it's ID GetByID(ctx context.Context, id string) (*model.ServerDetail, error) + // Publish adds a new ServerDetail to the database + Publish(ctx context.Context, serverDetail *model.ServerDetail) error // Close closes the database connection Close() error } diff --git a/internal/database/memory.go b/internal/database/memory.go index dc7dd0a..126f435 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -63,7 +63,7 @@ func (db *MemoryDB) List(ctx context.Context, filter map[string]interface{}, cur include = false } case "version": - if entry.Version != value.(string) { + if entry.VersionDetail.Version != value.(string) { include = false } // Add more filter options as needed @@ -125,17 +125,54 @@ func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerDetail if entry, exists := db.entries[id]; exists { return &model.ServerDetail{ - ID: entry.ID, - Name: entry.Name, - Description: entry.Description, - Version: entry.Version, - Repository: entry.Repository, + ID: entry.ID, + Name: entry.Name, + Description: entry.Description, + VersionDetail: entry.VersionDetail, + Repository: entry.Repository, }, 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 + + for _, entry := range db.entries { + if entry.Name == serverDetail.Name && entry.VersionDetail.Version == serverDetail.VersionDetail.Version { + return ErrAlreadyExists + } + } + + if serverDetail.Repository.URL == "" { + return ErrInvalidInput + } + + db.entries[serverDetail.ID] = &model.Entry{ + ID: serverDetail.ID, + Name: serverDetail.Name, + Description: serverDetail.Description, + VersionDetail: serverDetail.VersionDetail, + Repository: serverDetail.Repository, + } + + return nil +} + // Close closes the database connection // For an in-memory database, this is a no-op func (db *MemoryDB) Close() error { diff --git a/internal/database/mongo.go b/internal/database/mongo.go index 68a2900..ab9e8c3 100644 --- a/internal/database/mongo.go +++ b/internal/database/mongo.go @@ -40,13 +40,17 @@ func NewMongoDB(ctx context.Context, connectionURI, databaseName, collectionName // Create indexes for better query performance models := []mongo.IndexModel{ { - Keys: bson.D{{Key: "name", Value: 1}}, - Options: options.Index().SetUnique(true), + Keys: bson.D{{Key: "name", Value: 1}}, }, { Keys: bson.D{{Key: "id", Value: 1}}, Options: options.Index().SetUnique(true), }, + // add an index for the combination of name and version + { + Keys: bson.D{{Key: "name", Value: 1}, {Key: "versiondetail.version", Value: 1}}, + Options: options.Index().SetUnique(true), + }, } _, err = collection.Indexes().CreateMany(ctx, models) @@ -73,7 +77,9 @@ func (db *MongoDB) List(ctx context.Context, filter map[string]interface{}, curs } // Convert Go map to MongoDB filter - mongoFilter := bson.M{} + mongoFilter := bson.M{ + "versiondetail.islatest": true, + } // Map common filter keys to MongoDB document paths for k, v := range filter { // Handle nested fields with dot notation @@ -164,6 +170,53 @@ func (db *MongoDB) GetByID(ctx context.Context, id string) (*model.ServerDetail, return &entry, nil } +// Publish adds a new ServerDetail to the database +func (db *MongoDB) Publish(ctx context.Context, serverDetail *model.ServerDetail) error { + if ctx.Err() != nil { + return ctx.Err() + } + // find a server detail with the same name and check that the current version is greater than the existing one + filter := bson.M{ + "name": serverDetail.Name, + "versiondetail.islatest": true, + } + + var existingEntry model.ServerDetail + err := db.collection.FindOne(ctx, filter).Decode(&existingEntry) + if err != nil && err != mongo.ErrNoDocuments { + return fmt.Errorf("error checking existing entry: %w", err) + } + + // check that the current version is greater than the existing one + if serverDetail.VersionDetail.Version <= existingEntry.VersionDetail.Version { + return fmt.Errorf("version must be greater than existing version") + } + + // update the existing entry to not be the latest version + if existingEntry.ID != "" { + _, err = db.collection.UpdateOne(ctx, bson.M{"id": existingEntry.ID}, bson.M{"$set": bson.M{"versiondetail.islatest": false}}) + if err != nil { + return fmt.Errorf("error updating existing entry: %w", err) + } + + } + + serverDetail.ID = uuid.New().String() + serverDetail.VersionDetail.IsLatest = true + serverDetail.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339) + + // Insert the entry into the database + _, err = db.collection.InsertOne(ctx, serverDetail) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + return ErrAlreadyExists + } + return fmt.Errorf("error inserting entry: %w", err) + } + + return nil +} + // Close closes the database connection func (db *MongoDB) Close() error { return db.client.Disconnect(context.Background()) diff --git a/internal/model/model.go b/internal/model/model.go index a95802e..c95f9ad 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1,11 +1,11 @@ package model type Entry struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Repository Repository `json:"repository,omitempty"` - Version string `json:"version,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Repository Repository `json:"repository,omitempty"` + VersionDetail VersionDetail `json:"version_detail,omitempty"` } type Repository struct { @@ -14,16 +14,21 @@ type Repository struct { Branch string `json:"branch,omitempty"` Commit string `json:"commit,omitempty"` } +type VersionDetail struct { + Version string `json:"version,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` //RFC 3339 date format + IsLatest bool `json:"is_latest,omitempty"` +} type ServerDetail struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Version string `json:"version,omitempty"` - Repository Repository `json:"repository,omitempty"` - RegistryCanonical string `json:"registry_canonical,omitempty"` - Registries []Registries `json:"registries,omitempty"` - Remotes []Remotes `json:"remotes,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + VersionDetail VersionDetail `json:"version_detail,omitempty"` + Repository Repository `json:"repository,omitempty"` + RegistryCanonical string `json:"registry_canonical,omitempty"` + Registries []Registries `json:"registries,omitempty"` + Remotes []Remotes `json:"remotes,omitempty"` } type Registries struct { diff --git a/internal/service/fake_service.go b/internal/service/fake_service.go index 9be7cad..c781391 100644 --- a/internal/service/fake_service.go +++ b/internal/service/fake_service.go @@ -28,7 +28,11 @@ func NewFakeRegistryService() RegistryService { Branch: "main", Commit: "abc123def456", }, - Version: "1.0.0", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + }, }, { ID: uuid.New().String(), @@ -40,7 +44,11 @@ func NewFakeRegistryService() RegistryService { Branch: "develop", Commit: "def456ghi789", }, - Version: "2.1.0", + VersionDetail: model.VersionDetail{ + Version: "0.9.0", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: false, + }, }, { ID: uuid.New().String(), @@ -52,7 +60,11 @@ func NewFakeRegistryService() RegistryService { Branch: "feature/mcp-server", Commit: "789jkl012mno", }, - Version: "0.9.5", + VersionDetail: model.VersionDetail{ + Version: "0.9.5", + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: false, + }, }, } @@ -103,6 +115,16 @@ func (s *fakeRegistryService) GetByID(id string) (*model.ServerDetail, error) { return serverDetail, nil } +// Publish adds a new server detail to the in-memory database +func (s *fakeRegistryService) Publish(serverDetail *model.ServerDetail) error { + // Create a timeout context for the database operation + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Use the database's Publish method to add the server detail + return s.db.Publish(ctx, serverDetail) +} + // Close closes the in-memory database connection func (s *fakeRegistryService) Close() error { return s.db.Close() diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index cacba2e..963ddfd 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -81,3 +81,21 @@ func (s *registryServiceImpl) GetByID(id string) (*model.ServerDetail, error) { return serverDetail, 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 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if serverDetail == nil { + return database.ErrInvalidInput + } + + err := s.db.Publish(ctx, serverDetail) + if err != nil { + return err + } + + return nil +} diff --git a/internal/service/service.go b/internal/service/service.go index ba63c3c..7fe3b9f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -6,4 +6,5 @@ import "github.com/modelcontextprotocol/registry/internal/model" type RegistryService interface { List(cursor string, limit int) ([]model.Entry, string, error) GetByID(id string) (*model.ServerDetail, error) + Publish(serverDetail *model.ServerDetail) error } diff --git a/scripts/test_publish.sh b/scripts/test_publish.sh new file mode 100755 index 0000000..d0c7e2c --- /dev/null +++ b/scripts/test_publish.sh @@ -0,0 +1,174 @@ +#!/bin/bash + +set -e + +# Default values +HOST="http://localhost:8080" +VERBOSE=false + +# Display usage information +function show_usage { + echo "Usage: $0 [options]" + echo "Options:" + echo " -h, --host Base URL of the MCP Registry service (default: http://localhost:8080)" + echo " -v, --verbose Show verbose output including full request payload" + echo " --help Show this help message" + exit 1 +} + +# Check if jq is installed +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed." + echo "Please install jq using your package manager, for example:" + echo " brew install jq (macOS)" + echo " apt-get install jq (Debian/Ubuntu)" + echo " yum install jq (CentOS/RHEL)" + exit 1 +fi + +# Check if the API is running +echo "Checking if the API is running at $HOST..." +health_check=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/v0/health" 2>/dev/null) +if [[ "$health_check" != "200" ]]; then + echo "Warning: API might not be running at $HOST (health check returned $health_check)" + echo "Do you want to continue anyway? (y/n)" + read -r proceed + if [[ ! "$proceed" =~ ^[Yy]$ ]]; then + echo "Exiting. Please start the API and try again." + exit 1 + fi + echo "Continuing as requested..." +else + echo "API is running at $HOST" +fi + +# Parse command line arguments +while [[ "$#" -gt 0 ]]; do + case $1 in + -h|--host) HOST="$2"; shift ;; + -v|--verbose) VERBOSE=true ;; + --help) show_usage ;; + *) echo "Unknown parameter: $1"; show_usage ;; + esac + shift +done + +# Create a temporary file for our JSON payload +PAYLOAD_FILE=$(mktemp) + +# Create sample server detail payload +cat > "$PAYLOAD_FILE" << EOF +{ + "name": "Test MCP Server", + "description": "A test server for MCP Registry", + "version_detail": { + "version": "1.0.2", + "release_date": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "is_latest": true + }, + "repository": { + "url": "https://github.com/example/test-mcp-server", + "branch": "main" + }, + "registry_canonical": "test-mcp-server", + "registries": [ + { + "name": "npm", + "package_name": "test-mcp-server", + "license": "MIT", + "command_arguments": { + "sub_commands": [ + { + "name": "start", + "description": "Start the server" + } + ], + "environment_variables": [ + { + "name": "PORT", + "description": "Port to run the server on", + "required": false + } + ] + } + } + ], + "remotes": [ + { + "transport_type": "http", + "url": "http://example.com/api" + } + ] +} +EOF + +# Show the payload if verbose mode is enabled +if $VERBOSE; then + echo "Request Payload:" + cat "$PAYLOAD_FILE" | jq '.' + echo "-------------------------------------" +fi + +# Test publish endpoint +echo "Testing publish endpoint: $HOST/v0/publish" +# Get response and status code in a single request +response_file=$(mktemp) +headers_file=$(mktemp) + +# Execute curl with response body to file and headers+status to another file +curl -s -X POST -H "Content-Type: application/json" \ + -d "@$PAYLOAD_FILE" \ + -D "$headers_file" \ + -o "$response_file" \ + "$HOST/v0/publish" + +# Read the response body +http_response=$(<"$response_file") + +# Extract the status code from the headers file +status_code=$(head -n1 "$headers_file" | awk '{print $2}') + +# Clean up temp files +rm "$response_file" "$headers_file" + +echo "Status Code: $status_code" + +# Check for status code in 2xx range (200, 201, 202, etc) +if [[ "${status_code:0:1}" == "2" ]]; then + # Parse JSON response with jq + echo "Response:" + echo "$http_response" | jq '.' 2>/dev/null || echo "$http_response" + + # Extract the server ID from the response + server_id=$(echo "$http_response" | jq -r '.id') + + echo "Publish successful with ID: $server_id" + + # If we got a valid ID, verify it was actually created by calling the servers endpoint + if [[ ! -z "$server_id" && "$server_id" != "null" ]]; then + echo "-------------------------------------" + echo "Verifying server was published by checking servers endpoint..." + verify_response=$(curl -s "$HOST/v0/servers/$server_id") + echo "Response from servers endpoint:" + echo "$verify_response" | jq '.' 2>/dev/null || echo "$verify_response" + echo "-------------------------------------" + echo "Server verification response:" + echo "$verify_response" | jq '.' 2>/dev/null || echo "$verify_response" + echo "Server verification successful" + else + echo "Error: No valid server ID returned from publish response" + echo "Response:" + echo "$http_response" | jq '.' 2>/dev/null || echo "$http_response" + exit 1 + fi + +else + echo "Response:" + echo "$http_response" | jq '.' 2>/dev/null || echo "$http_response" + echo "Publish failed" +fi + +echo "-------------------------------------" + +# Clean up temp file +rm "$PAYLOAD_FILE" diff --git a/tools/importer/main.go b/tools/importer/main.go index 2a52569..9f7ab41 100644 --- a/tools/importer/main.go +++ b/tools/importer/main.go @@ -48,7 +48,7 @@ func init() { func main() { // Create a context with timeout for the database operations - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Handle graceful shutdown @@ -146,10 +146,14 @@ func convertToServerDetails(rawData []map[string]interface{}) []model.ServerDeta server.Description = desc } if version, ok := data["version"].(string); ok { - server.Version = version - } - if canonical, ok := data["registrycanonical"].(string); ok { - server.RegistryCanonical = canonical + if version == "" || version == "latest" { + version = "0.0.1" + } + server.VersionDetail = model.VersionDetail{ + Version: version, + ReleaseDate: time.Now().Format(time.RFC3339), + IsLatest: true, + } } // Extract repository @@ -339,6 +343,12 @@ func importData(ctx context.Context, collection *mongo.Collection, servers []mod // Create filter based on server ID filter := bson.M{"id": server.ID} + if server.VersionDetail.Version == "" { + server.VersionDetail.Version = "0.0.1" + server.VersionDetail.ReleaseDate = time.Now().Format(time.RFC3339) + server.VersionDetail.IsLatest = true + + } // Create update document update := bson.M{"$set": server}