diff --git a/Makefile b/Makefile index 7d490f3..efdbad7 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ publisher: ## Build the publisher tool # Test targets test-unit: ## Run unit tests with coverage - go test -v -race -coverprofile=coverage.out -covermode=atomic ./internal/... + go test -v -race -coverprofile=coverage.out -covermode=atomic -coverpkg=./internal/... ./internal/... go tool cover -html=coverage.out -o coverage.html @echo "Coverage report generated: coverage.html" diff --git a/cmd/registry/main.go b/cmd/registry/main.go index f189db0..f2f5f5c 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -14,9 +14,9 @@ import ( "github.com/modelcontextprotocol/registry/internal/api" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/database" + "github.com/modelcontextprotocol/registry/internal/importer" "github.com/modelcontextprotocol/registry/internal/service" "github.com/modelcontextprotocol/registry/internal/telemetry" - apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" ) func main() { @@ -46,7 +46,7 @@ func main() { // Initialize services based on environment switch cfg.DatabaseType { case config.DatabaseTypeMemory: - db = database.NewMemoryDB(map[string]*apiv0.ServerJSON{}) + db = database.NewMemoryDB() registryService = service.NewRegistryServiceWithDB(db) case config.DatabaseTypePostgreSQL: // Use PostgreSQL for real registry service @@ -84,7 +84,8 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - if err := db.ImportSeed(ctx, cfg.SeedFrom); err != nil { + importerService := importer.NewService(db) + if err := importerService.ImportFromPath(ctx, cfg.SeedFrom); err != nil { log.Printf("Failed to import seed data: %v", err) } else { log.Println("Data import completed successfully") diff --git a/go.mod b/go.mod index f70ae43..3b980f5 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,6 @@ require ( github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f // indirect github.com/prometheus/procfs v0.17.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect 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 diff --git a/internal/api/handlers/v0/edit_test.go b/internal/api/handlers/v0/edit_test.go index 145f501..b99d8f3 100644 --- a/internal/api/handlers/v0/edit_test.go +++ b/internal/api/handlers/v0/edit_test.go @@ -3,7 +3,6 @@ package v0_test import ( "bytes" "encoding/json" - "errors" "net/http" "net/http/httptest" "testing" @@ -11,29 +10,96 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/database" + "github.com/modelcontextprotocol/registry/internal/service" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/modelcontextprotocol/registry/pkg/model" ) func TestEditServerEndpoint(t *testing.T) { + // Create registry service and insert a common test server + registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB()) + + // Publish a test server that will be used across test cases + testServer := apiv0.ServerJSON{ + Name: "io.github.domdomegg/test-server", + Description: "Original test server", + Status: model.StatusActive, + Repository: model.Repository{ + URL: "https://github.com/domdomegg/test-server", + Source: "github", + ID: "domdomegg/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + }, + } + published, err := registryService.Publish(testServer) + assert.NoError(t, err) + assert.NotNil(t, published) + assert.NotNil(t, published.Meta) + assert.NotNil(t, published.Meta.IOModelContextProtocolRegistry) + + testServerID := published.Meta.IOModelContextProtocolRegistry.ID + + // Publish a second server for permission testing + otherServer := apiv0.ServerJSON{ + Name: "io.github.other/test-server", + Description: "Other test server", + Status: model.StatusActive, + Repository: model.Repository{ + URL: "https://github.com/other/test-server", + Source: "github", + ID: "other/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + }, + } + otherPublished, err := registryService.Publish(otherServer) + assert.NoError(t, err) + assert.NotNil(t, otherPublished) + assert.NotNil(t, otherPublished.Meta) + assert.NotNil(t, otherPublished.Meta.IOModelContextProtocolRegistry) + + otherServerID := otherPublished.Meta.IOModelContextProtocolRegistry.ID + + // Publish a deleted server for undelete testing + deletedServer := apiv0.ServerJSON{ + Name: "io.github.domdomegg/deleted-server", + Description: "Deleted test server", + Status: model.StatusDeleted, + Repository: model.Repository{ + URL: "https://github.com/domdomegg/deleted-server", + Source: "github", + ID: "domdomegg/deleted-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + }, + } + deletedPublished, err := registryService.Publish(deletedServer) + assert.NoError(t, err) + assert.NotNil(t, deletedPublished) + assert.NotNil(t, deletedPublished.Meta) + assert.NotNil(t, deletedPublished.Meta.IOModelContextProtocolRegistry) + + deletedServerID := deletedPublished.Meta.IOModelContextProtocolRegistry.ID + testCases := []struct { name string - serverID string authHeader string requestBody interface{} - setupMocks func(*MockRegistryService) + serverID string expectedStatus int expectedError string }{ { - name: "successful edit with valid token and permissions", - serverID: "550e8400-e29b-41d4-a716-446655440001", + name: "successful edit with valid token and permissions", authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ @@ -58,62 +124,43 @@ func TestEditServerEndpoint(t *testing.T) { Version: "1.0.1", }, }, - setupMocks: func(registry *MockRegistryService) { - // Current server (not deleted) - currentServer := &apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", - Description: "Original server", - Status: model.StatusActive, - } - registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) - - expectedResponse := &apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", - Description: "Updated test server", - Status: model.StatusDeprecated, - } - registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("v0.ServerJSON")).Return(expectedResponse, nil) - }, + serverID: testServerID, expectedStatus: http.StatusOK, }, { name: "missing authorization header", - serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: "", requestBody: apiv0.ServerJSON{}, - setupMocks: func(_ *MockRegistryService) {}, + serverID: testServerID, expectedStatus: 422, expectedError: "required header parameter is missing", }, { name: "invalid authorization header format", - serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: "InvalidFormat token123", requestBody: apiv0.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Test server", VersionDetail: model.VersionDetail{Version: "1.0.0"}, }, - setupMocks: func(_ *MockRegistryService) {}, - expectedStatus: http.StatusUnauthorized, - expectedError: "Unauthorized", + serverID: testServerID, + expectedStatus: http.StatusUnauthorized, + expectedError: "Unauthorized", }, { name: "invalid token", - serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: "Bearer invalid-token", requestBody: apiv0.ServerJSON{ Name: "io.github.domdomegg/test-server", Description: "Test server", VersionDetail: model.VersionDetail{Version: "1.0.0"}, }, - setupMocks: func(_ *MockRegistryService) {}, - expectedStatus: http.StatusUnauthorized, - expectedError: "Unauthorized", + serverID: testServerID, + expectedStatus: http.StatusUnauthorized, + expectedError: "Unauthorized", }, { name: "permission denied - no edit permissions", - serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ @@ -126,24 +173,16 @@ func TestEditServerEndpoint(t *testing.T) { return "Bearer " + token }(), requestBody: apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", - Description: "Updated test server", - }, - setupMocks: func(registry *MockRegistryService) { - // Need to mock GetByID since we check permissions against existing server name - currentServer := &apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", - Description: "Original server", - Status: model.StatusActive, - } - registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) + Name: "io.github.domdomegg/test-server", + Description: "Updated test server", + VersionDetail: model.VersionDetail{Version: "1.0.0"}, }, + serverID: testServerID, expectedStatus: http.StatusForbidden, expectedError: "Forbidden", }, { name: "permission denied - wrong resource", - serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ @@ -156,25 +195,16 @@ func TestEditServerEndpoint(t *testing.T) { return "Bearer " + token }(), requestBody: apiv0.ServerJSON{ - Name: "io.github.other/test-server", - Description: "Updated test server", - }, - setupMocks: func(registry *MockRegistryService) { - // Need to mock GetByID since we check permissions against existing server name - // This test case shows a different scenario: existing server is "other" but user only has perms for "domdomegg" - currentServer := &apiv0.ServerJSON{ - Name: "io.github.other/test-server", - Description: "Original server", - Status: model.StatusActive, - } - registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) + Name: "io.github.other/test-server", + Description: "Updated test server", + VersionDetail: model.VersionDetail{Version: "1.0.0"}, }, + serverID: otherServerID, expectedStatus: http.StatusForbidden, expectedError: "Forbidden", }, { name: "server not found", - serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ @@ -187,49 +217,16 @@ func TestEditServerEndpoint(t *testing.T) { return "Bearer " + token }(), requestBody: apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", - Description: "Updated test server", - }, - setupMocks: func(registry *MockRegistryService) { - registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(nil, database.ErrNotFound) + Name: "io.github.domdomegg/nonexistent-server", + Description: "Updated test server", + VersionDetail: model.VersionDetail{Version: "1.0.0"}, }, + serverID: "550e8400-e29b-41d4-a716-446655440999", // Non-existent ID expectedStatus: http.StatusNotFound, expectedError: "Not Found", }, - { - name: "service error", - serverID: "550e8400-e29b-41d4-a716-446655440001", - authHeader: func() string { - cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} - token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ - AuthMethod: auth.MethodGitHubAT, - AuthMethodSubject: "domdomegg", - Permissions: []auth.Permission{ - {Action: auth.PermissionActionEdit, ResourcePattern: "*"}, - }, - }) - return "Bearer " + token - }(), - requestBody: apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", - Description: "Updated test server", - }, - setupMocks: func(registry *MockRegistryService) { - // Current server (not deleted) - currentServer := &apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", - Description: "Original server", - Status: model.StatusActive, - } - registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) - registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("v0.ServerJSON")).Return(nil, errors.New("database error")) - }, - expectedStatus: http.StatusBadRequest, - expectedError: "Bad Request", - }, { name: "validation error - invalid server name", - serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ @@ -246,20 +243,12 @@ func TestEditServerEndpoint(t *testing.T) { Description: "Test server", VersionDetail: model.VersionDetail{Version: "1.0.0"}, }, - setupMocks: func(registry *MockRegistryService) { - currentServer := &apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", - Description: "Original server", - Status: model.StatusActive, - } - registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) - }, + serverID: testServerID, expectedStatus: http.StatusBadRequest, expectedError: "Bad Request", }, { name: "cannot undelete server", - serverID: "550e8400-e29b-41d4-a716-446655440001", authHeader: func() string { cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"} token, _ := generateTestJWTToken(cfg, auth.JWTClaims{ @@ -272,19 +261,19 @@ func TestEditServerEndpoint(t *testing.T) { return "Bearer " + token }(), requestBody: apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", + Name: "io.github.domdomegg/deleted-server", Description: "Trying to undelete server", Status: model.StatusActive, + Repository: model.Repository{ + URL: "https://github.com/domdomegg/deleted-server", + Source: "github", + ID: "domdomegg/deleted-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.1", + }, }, - setupMocks: func(registry *MockRegistryService) { - // Current server is deleted - currentServer := &apiv0.ServerJSON{ - Name: "io.github.domdomegg/test-server", - Description: "Original server", - Status: model.StatusDeleted, - } - registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil) - }, + serverID: deletedServerID, expectedStatus: http.StatusBadRequest, expectedError: "Cannot change status of deleted server", }, @@ -292,10 +281,6 @@ func TestEditServerEndpoint(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Create mock registry - mockRegistry := new(MockRegistryService) - tc.setupMocks(mockRegistry) - // Create Huma API mux := http.NewServeMux() humaConfig := huma.DefaultConfig("Test API", "1.0.0") @@ -305,7 +290,7 @@ func TestEditServerEndpoint(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c", } - v0.RegisterEditEndpoints(api, mockRegistry, cfg) + v0.RegisterEditEndpoints(api, registryService, cfg) // Create request body var requestBody []byte @@ -338,7 +323,7 @@ func TestEditServerEndpoint(t *testing.T) { assert.Contains(t, w.Body.String(), tc.expectedError) } - mockRegistry.AssertExpectations(t) + // No mock assertions needed with real service }) } } diff --git a/internal/api/handlers/v0/publish_integration_test.go b/internal/api/handlers/v0/publish_integration_test.go index f92ee86..dfdb152 100644 --- a/internal/api/handlers/v0/publish_integration_test.go +++ b/internal/api/handlers/v0/publish_integration_test.go @@ -16,6 +16,7 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" + "github.com/modelcontextprotocol/registry/internal/database" "github.com/modelcontextprotocol/registry/internal/service" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/modelcontextprotocol/registry/pkg/model" @@ -36,7 +37,7 @@ func generateIntegrationTestJWTToken(cfg *config.Config, claims auth.JWTClaims) func TestPublishIntegration(t *testing.T) { // Setup fake service - registryService := service.NewFakeRegistryService() + registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB()) // Create test config with a valid Ed25519 seed testSeed := make([]byte, ed25519.SeedSize) diff --git a/internal/api/handlers/v0/publish_test.go b/internal/api/handlers/v0/publish_test.go index 67ccc26..68348c8 100644 --- a/internal/api/handlers/v0/publish_test.go +++ b/internal/api/handlers/v0/publish_test.go @@ -7,7 +7,6 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" - "errors" "net/http" "net/http/httptest" "testing" @@ -17,46 +16,14 @@ import ( v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" + "github.com/modelcontextprotocol/registry/internal/database" + "github.com/modelcontextprotocol/registry/internal/service" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/modelcontextprotocol/registry/pkg/model" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -// MockRegistryService is a mock implementation of the RegistryService interface -type MockRegistryService struct { - mock.Mock -} - -func (m *MockRegistryService) List(cursor string, limit int) ([]apiv0.ServerJSON, string, error) { - args := m.Called(cursor, limit) - return args.Get(0).([]apiv0.ServerJSON), args.String(1), args.Error(2) -} - -func (m *MockRegistryService) GetByID(id string) (*apiv0.ServerJSON, error) { - args := m.Called(id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*apiv0.ServerJSON), args.Error(1) -} - -func (m *MockRegistryService) Publish(request apiv0.ServerJSON) (*apiv0.ServerJSON, error) { - args := m.Called(request) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*apiv0.ServerJSON), args.Error(1) -} - -func (m *MockRegistryService) EditServer(id string, request apiv0.ServerJSON) (*apiv0.ServerJSON, error) { - args := m.Called(id, request) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*apiv0.ServerJSON), args.Error(1) -} // Helper function to generate a valid JWT token for testing func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) { @@ -82,7 +49,7 @@ func TestPublishEndpoint(t *testing.T) { requestBody interface{} tokenClaims *auth.JWTClaims authHeader string - setupMocks func(*MockRegistryService) + setupRegistryService func(service.RegistryService) expectedStatus int expectedError string }{ @@ -107,8 +74,8 @@ func TestPublishEndpoint(t *testing.T) { {Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"}, }, }, - setupMocks: func(registry *MockRegistryService) { - registry.On("Publish", mock.AnythingOfType("v0.ServerJSON")).Return(&apiv0.ServerJSON{}, nil) + setupRegistryService: func(_ service.RegistryService) { + // Empty registry - no setup needed }, expectedStatus: http.StatusOK, }, @@ -132,8 +99,8 @@ func TestPublishEndpoint(t *testing.T) { {Action: auth.PermissionActionPublish, ResourcePattern: "example/*"}, }, }, - setupMocks: func(registry *MockRegistryService) { - registry.On("Publish", mock.AnythingOfType("v0.ServerJSON")).Return(&apiv0.ServerJSON{}, nil) + setupRegistryService: func(_ service.RegistryService) { + // Empty registry - no setup needed }, expectedStatus: http.StatusOK, }, @@ -141,7 +108,9 @@ func TestPublishEndpoint(t *testing.T) { name: "missing authorization header", requestBody: apiv0.ServerJSON{}, authHeader: "", // Empty auth header - setupMocks: func(_ *MockRegistryService) {}, + setupRegistryService: func(_ service.RegistryService) { + // Empty registry - no setup needed + }, expectedStatus: http.StatusUnprocessableEntity, expectedError: "required header parameter is missing", }, @@ -153,7 +122,9 @@ func TestPublishEndpoint(t *testing.T) { VersionDetail: model.VersionDetail{Version: "1.0.0"}, }, authHeader: "InvalidFormat", - setupMocks: func(_ *MockRegistryService) {}, + setupRegistryService: func(_ service.RegistryService) { + // Empty registry - no setup needed + }, expectedStatus: http.StatusUnauthorized, expectedError: "Invalid Authorization header format", }, @@ -167,7 +138,9 @@ func TestPublishEndpoint(t *testing.T) { }, }, authHeader: "Bearer invalidToken", - setupMocks: func(_ *MockRegistryService) {}, + setupRegistryService: func(_ service.RegistryService) { + // Empty registry - no setup needed + }, expectedStatus: http.StatusUnauthorized, expectedError: "Invalid or expired Registry JWT token", }, @@ -191,7 +164,9 @@ func TestPublishEndpoint(t *testing.T) { {Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"}, }, }, - setupMocks: func(_ *MockRegistryService) {}, + setupRegistryService: func(_ service.RegistryService) { + // Empty registry - no setup needed + }, expectedStatus: http.StatusForbidden, expectedError: "You do not have permission to publish this server", }, @@ -215,28 +190,41 @@ func TestPublishEndpoint(t *testing.T) { {Action: auth.PermissionActionPublish, ResourcePattern: "*"}, }, }, - setupMocks: func(registry *MockRegistryService) { - registry.On("Publish", mock.AnythingOfType("v0.ServerJSON")).Return(nil, errors.New("cannot publish duplicate version")) + setupRegistryService: func(registry service.RegistryService) { + // Pre-publish the same server to cause duplicate version error + existingServer := apiv0.ServerJSON{ + Name: "example/test-server", + Description: "Existing test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + }, + Repository: model.Repository{ + URL: "https://github.com/example/test-server-existing", + Source: "github", + ID: "example/test-server-existing", + }, + } + _, _ = registry.Publish(existingServer) }, expectedStatus: http.StatusBadRequest, - expectedError: "Failed to publish server", + expectedError: "invalid version: cannot publish duplicate version", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Create mocks - mockRegistry := new(MockRegistryService) - - // Setup mocks - tc.setupMocks(mockRegistry) + // Create registry service + registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB()) + + // Setup registry service + tc.setupRegistryService(registryService) // Create a new ServeMux and Huma API mux := http.NewServeMux() api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) // Register the endpoint with test config - v0.RegisterPublishEndpoint(api, mockRegistry, testConfig) + v0.RegisterPublishEndpoint(api, registryService, testConfig) // Prepare request body var requestBody []byte @@ -272,8 +260,7 @@ func TestPublishEndpoint(t *testing.T) { assert.Contains(t, rr.Body.String(), tc.expectedError) } - // Verify mock expectations - mockRegistry.AssertExpectations(t) + // No mock expectations to verify }) } } diff --git a/internal/api/handlers/v0/servers_test.go b/internal/api/handlers/v0/servers_test.go index 2f041fd..59564b3 100644 --- a/internal/api/handlers/v0/servers_test.go +++ b/internal/api/handlers/v0/servers_test.go @@ -3,7 +3,6 @@ package v0_test import ( "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "testing" @@ -12,67 +11,29 @@ import ( "github.com/danielgtaylor/huma/v2/adapters/humago" "github.com/google/uuid" v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" + "github.com/modelcontextprotocol/registry/internal/database" + "github.com/modelcontextprotocol/registry/internal/service" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/modelcontextprotocol/registry/pkg/model" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" ) func TestServersListEndpoint(t *testing.T) { testCases := []struct { - name string - queryParams string - setupMocks func(*MockRegistryService) - expectedStatus int - expectedServers []apiv0.ServerJSON - expectedMeta *v0.Metadata - expectedError string + name string + queryParams string + setupRegistryService func(service.RegistryService) + expectedStatus int + expectedServers []apiv0.ServerJSON + expectedMeta *v0.Metadata + expectedError string }{ { name: "successful list with default parameters", - setupMocks: func(registry *MockRegistryService) { - servers := []apiv0.ServerJSON{ - { - Name: "test-server-1", - Description: "First test server", - Repository: model.Repository{ - URL: "https://github.com/example/test-server-1", - Source: "github", - ID: "example/test-server-1", - }, - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "550e8400-e29b-41d4-a716-446655440001", - }, - }, - }, - { - Name: "test-server-2", - Description: "Second test server", - Repository: model.Repository{ - URL: "https://github.com/example/test-server-2", - Source: "github", - ID: "example/test-server-2", - }, - VersionDetail: model.VersionDetail{ - Version: "2.0.0", - }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "550e8400-e29b-41d4-a716-446655440002", - }, - }, - }, - } - registry.Mock.On("List", "", 30).Return(servers, "", nil) - }, - expectedStatus: http.StatusOK, - expectedServers: []apiv0.ServerJSON{ - { - Name: "test-server-1", + setupRegistryService: func(registry service.RegistryService) { + // Publish test servers + server1 := apiv0.ServerJSON{ + Name: "com.example/test-server-1", Description: "First test server", Repository: model.Repository{ URL: "https://github.com/example/test-server-1", @@ -82,14 +43,9 @@ func TestServersListEndpoint(t *testing.T) { VersionDetail: model.VersionDetail{ Version: "1.0.0", }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "550e8400-e29b-41d4-a716-446655440001", - }, - }, - }, - { - Name: "test-server-2", + } + server2 := apiv0.ServerJSON{ + Name: "com.example/test-server-2", Description: "Second test server", Repository: model.Repository{ URL: "https://github.com/example/test-server-2", @@ -99,44 +55,19 @@ func TestServersListEndpoint(t *testing.T) { VersionDetail: model.VersionDetail{ Version: "2.0.0", }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "550e8400-e29b-41d4-a716-446655440002", - }, - }, - }, + } + _, _ = registry.Publish(server1) + _, _ = registry.Publish(server2) }, + expectedStatus: http.StatusOK, + expectedServers: nil, // Will be verified differently since IDs are dynamic }, { name: "successful list with cursor and limit", - queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000&limit=10", - setupMocks: func(registry *MockRegistryService) { - servers := []apiv0.ServerJSON{ - { - Name: "test-server-3", - Description: "Third test server", - Repository: model.Repository{ - URL: "https://github.com/example/test-server-3", - Source: "github", - ID: "example/test-server-3", - }, - VersionDetail: model.VersionDetail{ - Version: "1.5.0", - }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "550e8400-e29b-41d4-a716-446655440003", - }, - }, - }, - } - nextCursor := uuid.New().String() - registry.Mock.On("List", mock.AnythingOfType("string"), 10).Return(servers, nextCursor, nil) - }, - expectedStatus: http.StatusOK, - expectedServers: []apiv0.ServerJSON{ - { - Name: "test-server-3", + queryParams: "?limit=10", + setupRegistryService: func(registry service.RegistryService) { + server := apiv0.ServerJSON{ + Name: "com.example/test-server-3", Description: "Third test server", Repository: model.Repository{ URL: "https://github.com/example/test-server-3", @@ -146,75 +77,69 @@ func TestServersListEndpoint(t *testing.T) { VersionDetail: model.VersionDetail{ Version: "1.5.0", }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "550e8400-e29b-41d4-a716-446655440003", - }, - }, - }, + } + _, _ = registry.Publish(server) }, - expectedMeta: &v0.Metadata{ - NextCursor: "", // This will be dynamically set in the test - Count: 1, + expectedStatus: http.StatusOK, + expectedServers: nil, // Will be verified differently since IDs are dynamic + }, + { + name: "successful list with limit capping at 100", + queryParams: "?limit=150", + setupRegistryService: func(_ service.RegistryService) {}, + expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum + expectedError: "validation failed", + }, + { + name: "invalid cursor parameter", + queryParams: "?cursor=invalid-uuid", + setupRegistryService: func(_ service.RegistryService) {}, + expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors + expectedError: "validation failed", + }, + { + name: "invalid limit parameter - non-numeric", + queryParams: "?limit=abc", + setupRegistryService: func(_ service.RegistryService) {}, + expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors + expectedError: "validation failed", + }, + { + name: "invalid limit parameter - zero", + queryParams: "?limit=0", + setupRegistryService: func(_ service.RegistryService) {}, + expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors + expectedError: "validation failed", + }, + { + name: "invalid limit parameter - negative", + queryParams: "?limit=-5", + setupRegistryService: func(_ service.RegistryService) {}, + expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors + expectedError: "validation failed", + }, + { + name: "empty registry returns success", + setupRegistryService: func(_ service.RegistryService) { + // Test empty registry - empty setup }, - }, - { - name: "successful list with limit capping at 100", - queryParams: "?limit=150", - setupMocks: func(_ *MockRegistryService) {}, - expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum - expectedError: "validation failed", - }, - { - name: "invalid cursor parameter", - queryParams: "?cursor=invalid-uuid", - setupMocks: func(_ *MockRegistryService) {}, - expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors - expectedError: "validation failed", - }, - { - name: "invalid limit parameter - non-numeric", - queryParams: "?limit=abc", - setupMocks: func(_ *MockRegistryService) {}, - expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors - expectedError: "validation failed", - }, - { - name: "invalid limit parameter - zero", - queryParams: "?limit=0", - setupMocks: func(_ *MockRegistryService) {}, - expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors - expectedError: "validation failed", - }, - { - name: "invalid limit parameter - negative", - queryParams: "?limit=-5", - setupMocks: func(_ *MockRegistryService) {}, - expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors - expectedError: "validation failed", - }, - { - name: "registry service error", - setupMocks: func(registry *MockRegistryService) { - registry.Mock.On("List", "", 30).Return([]apiv0.ServerJSON{}, "", errors.New("database connection error")) - }, - expectedStatus: http.StatusInternalServerError, - expectedError: "Failed to get registry list", + expectedStatus: http.StatusOK, + expectedServers: []apiv0.ServerJSON{}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create mock registry service - mockRegistry := new(MockRegistryService) - tc.setupMocks(mockRegistry) + registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB()) + tc.setupRegistryService(registryService) // Create a new test API mux := http.NewServeMux() api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) // Register the servers endpoints - v0.RegisterServersEndpoints(api, mockRegistry) + v0.RegisterServersEndpoints(api, registryService) // Create request url := "/v0/servers" + tc.queryParams @@ -242,13 +167,26 @@ func TestServersListEndpoint(t *testing.T) { // Check the response data if tc.expectedServers != nil { assert.Equal(t, tc.expectedServers, resp.Servers) + } else { + // For tests with dynamic data, check structure and count + assert.NotEmpty(t, resp.Servers, "Expected at least one server") + for _, server := range resp.Servers { + assert.NotEmpty(t, server.Name) + assert.NotEmpty(t, server.Description) + assert.NotNil(t, server.Meta) + assert.NotNil(t, server.Meta.IOModelContextProtocolRegistry) + assert.NotEmpty(t, server.Meta.IOModelContextProtocolRegistry.ID) + } } // Check metadata if expected if tc.expectedMeta != nil { - assert.Equal(t, tc.expectedMeta.Count, resp.Metadata.Count) - if tc.expectedMeta.NextCursor != "" { - assert.NotEmpty(t, resp.Metadata.NextCursor) + assert.NotNil(t, resp.Metadata, "Expected metadata to be present") + if resp.Metadata != nil { + assert.Equal(t, tc.expectedMeta.Count, resp.Metadata.Count) + if tc.expectedMeta.NextCursor != "" { + assert.NotEmpty(t, resp.Metadata.NextCursor) + } } } } else if tc.expectedError != "" { @@ -257,84 +195,58 @@ func TestServersListEndpoint(t *testing.T) { } // Verify mock expectations - mockRegistry.AssertExpectations(t) + // No expectations to verify with real service }) } } func TestServersDetailEndpoint(t *testing.T) { + // Create mock registry service + registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB()) + + testServer, err := registryService.Publish(apiv0.ServerJSON{ + Name: "com.example/test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + }, + }) + assert.NoError(t, err) + testCases := []struct { name string serverID string - setupMocks func(*MockRegistryService, string) expectedStatus int expectedServer *apiv0.ServerJSON expectedError string }{ { - name: "successful get server detail", - serverID: uuid.New().String(), - setupMocks: func(registry *MockRegistryService, serverID string) { - serverDetail := &apiv0.ServerJSON{ - Name: "test-server-detail", - Description: "Test server detail", - Repository: model.Repository{ - URL: "https://github.com/example/test-server-detail", - Source: "github", - ID: "example/test-server-detail", - }, - VersionDetail: model.VersionDetail{ - Version: "2.0.0", - }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: serverID, - }, - }, - } - registry.Mock.On("GetByID", serverID).Return(serverDetail, nil) - }, + name: "successful get server detail", + serverID: testServer.Meta.IOModelContextProtocolRegistry.ID, expectedStatus: http.StatusOK, }, { name: "invalid server ID format", serverID: "invalid-uuid", - setupMocks: func(_ *MockRegistryService, _ string) {}, expectedStatus: http.StatusUnprocessableEntity, expectedError: "validation failed", }, { - name: "server not found", - serverID: uuid.New().String(), - setupMocks: func(registry *MockRegistryService, serverID string) { - registry.Mock.On("GetByID", serverID).Return(nil, errors.New("record not found")) - }, + name: "server not found", + serverID: uuid.New().String(), expectedStatus: http.StatusNotFound, expectedError: "Server not found", }, - { - name: "registry service error", - serverID: uuid.New().String(), - setupMocks: func(registry *MockRegistryService, serverID string) { - registry.Mock.On("GetByID", serverID).Return(nil, errors.New("database connection error")) - }, - expectedStatus: http.StatusInternalServerError, - expectedError: "Failed to get server details", - }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Create mock registry service - mockRegistry := new(MockRegistryService) - tc.setupMocks(mockRegistry, tc.serverID) - // Create a new test API mux := http.NewServeMux() api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) // Register the servers endpoints - v0.RegisterServersEndpoints(api, mockRegistry) + v0.RegisterServersEndpoints(api, registryService) // Create request url := "/v0/servers/" + tc.serverID @@ -364,7 +276,7 @@ func TestServersDetailEndpoint(t *testing.T) { } // Verify mock expectations - mockRegistry.AssertExpectations(t) + // No expectations to verify with real service }) } } @@ -372,42 +284,36 @@ func TestServersDetailEndpoint(t *testing.T) { // TestServersEndpointsIntegration tests the servers endpoints with actual HTTP requests func TestServersEndpointsIntegration(t *testing.T) { // Create mock registry service - mockRegistry := new(MockRegistryService) + registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB()) - // Test data - serverID := uuid.New().String() - servers := []apiv0.ServerJSON{ - { - Name: "integration-test-server", - Description: "Integration test server", - Repository: model.Repository{ - URL: "https://github.com/example/integration-test", - Source: "github", - ID: "example/integration-test", - }, - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: serverID, - }, - }, + // Test data - publish a server and get its actual ID + testServer := apiv0.ServerJSON{ + Name: "com.example/integration-test-server", + Description: "Integration test server", + Repository: model.Repository{ + URL: "https://github.com/example/integration-test", + Source: "github", + ID: "example/integration-test", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", }, } - serverDetail := &servers[0] + published, err := registryService.Publish(testServer) + assert.NoError(t, err) + assert.NotNil(t, published) - // Setup mocks - mockRegistry.Mock.On("List", "", 30).Return(servers, "", nil) - mockRegistry.Mock.On("GetByID", serverID).Return(serverDetail, nil) + serverID := published.Meta.IOModelContextProtocolRegistry.ID + servers := []apiv0.ServerJSON{*published} + serverDetail := published // Create a new test API mux := http.NewServeMux() api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) // Register the servers endpoints - v0.RegisterServersEndpoints(api, mockRegistry) + v0.RegisterServersEndpoints(api, registryService) // Create test server server := httptest.NewServer(mux) @@ -441,8 +347,14 @@ func TestServersEndpointsIntegration(t *testing.T) { err = json.NewDecoder(resp.Body).Decode(&listResp) assert.NoError(t, err) - // Check the response data - assert.Equal(t, servers, listResp.Servers) + // Check the response data (excluding timestamps which will be different) + assert.Len(t, listResp.Servers, len(servers)) + if len(listResp.Servers) > 0 { + assert.Equal(t, servers[0].Name, listResp.Servers[0].Name) + assert.Equal(t, servers[0].Description, listResp.Servers[0].Description) + assert.Equal(t, servers[0].Repository, listResp.Servers[0].Repository) + assert.Equal(t, servers[0].VersionDetail, listResp.Servers[0].VersionDetail) + } }) // Test get server detail endpoint @@ -471,10 +383,13 @@ func TestServersEndpointsIntegration(t *testing.T) { err = json.NewDecoder(resp.Body).Decode(&serverDetailResp) assert.NoError(t, err) - // Check the response data - assert.Equal(t, *serverDetail, serverDetailResp) + // Check the response data (excluding timestamps which will be different) + assert.Equal(t, serverDetail.Name, serverDetailResp.Name) + assert.Equal(t, serverDetail.Description, serverDetailResp.Description) + assert.Equal(t, serverDetail.Repository, serverDetailResp.Repository) + assert.Equal(t, serverDetail.VersionDetail, serverDetailResp.VersionDetail) }) // Verify mock expectations - mockRegistry.AssertExpectations(t) + // No expectations to verify with real service } diff --git a/internal/api/handlers/v0/telemetry_test.go b/internal/api/handlers/v0/telemetry_test.go index 44eb2c0..cea593e 100644 --- a/internal/api/handlers/v0/telemetry_test.go +++ b/internal/api/handlers/v0/telemetry_test.go @@ -8,43 +8,33 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" - "github.com/google/uuid" "github.com/stretchr/testify/assert" v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/api/router" "github.com/modelcontextprotocol/registry/internal/config" + "github.com/modelcontextprotocol/registry/internal/database" + "github.com/modelcontextprotocol/registry/internal/service" "github.com/modelcontextprotocol/registry/internal/telemetry" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/modelcontextprotocol/registry/pkg/model" ) -func mockServerEndpoint(registry *MockRegistryService, serverID string) { - serverDetail := &apiv0.ServerJSON{ - Name: "test-server-detail", +func TestPrometheusHandler(t *testing.T) { + registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB()) + server, err := registryService.Publish(apiv0.ServerJSON{ + Name: "io.github.example/test-server", Description: "Test server detail", Repository: model.Repository{ - URL: "https://github.com/example/test-server-detail", + URL: "https://github.com/example/test-server", Source: "github", - ID: "example/test-server-detail", + ID: "example/test-server", }, VersionDetail: model.VersionDetail{ Version: "2.0.0", }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: serverID, - }, - }, - } - registry.Mock.On("GetByID", serverID).Return(serverDetail, nil) -} - -func TestPrometheusHandler(t *testing.T) { - mockRegistry := new(MockRegistryService) - - serverID := uuid.New().String() - mockServerEndpoint(mockRegistry, serverID) + }) + assert.NoError(t, err) cfg := config.NewConfig() shutdownTelemetry, metrics, _ := telemetry.InitMetrics("dev") @@ -57,13 +47,13 @@ func TestPrometheusHandler(t *testing.T) { router.WithSkipPaths("/health", "/metrics", "/ping", "/docs"), )) v0.RegisterHealthEndpoint(api, cfg, metrics) - v0.RegisterServersEndpoints(api, mockRegistry) + v0.RegisterServersEndpoints(api, registryService) // Add /metrics for Prometheus metrics using promhttp mux.Handle("/metrics", metrics.PrometheusHandler()) // Create request - url := "/v0/servers/" + serverID + url := "/v0/servers/" + server.Meta.IOModelContextProtocolRegistry.ID req := httptest.NewRequest(http.MethodGet, url, nil) w := httptest.NewRecorder() diff --git a/internal/database/database.go b/internal/database/database.go index f731756..ac1d519 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -17,21 +17,22 @@ var ( 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 +// ServerFilter defines filtering options for server queries +type ServerFilter struct { + Name *string // for finding versions of same server + RemoteURL *string // for duplicate URL detection +} + +// Database defines the interface for database operations type Database interface { - // List retrieves all ServerRecord entries with optional filtering - List(ctx context.Context, filter map[string]any, cursor string, limit int) ([]*apiv0.ServerJSON, string, error) - // GetByID retrieves a single ServerRecord by its ID + // Retrieve server entries with optional filtering + List(ctx context.Context, filter *ServerFilter, cursor string, limit int) ([]*apiv0.ServerJSON, string, error) + // Retrieve a single server by its ID GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) - // Publish adds a new server to the database with separated server.json and extensions - // The registryMetadata contains metadata determined by the service layer (e.g., is_latest, timestamps) - Publish(ctx context.Context, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv0.RegistryExtensions) (*apiv0.ServerJSON, error) - // UpdateLatestFlag updates the is_latest flag for a specific server record - UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error - // UpdateServer updates an existing server record with new server details - UpdateServer(ctx context.Context, id string, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}) (*apiv0.ServerJSON, error) - // ImportSeed imports initial data from a seed file - ImportSeed(ctx context.Context, seedFilePath string) error + // CreateServer adds a new server to the database + CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) + // UpdateServer updates an existing server record + UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) // Close closes the database connection Close() error } @@ -45,15 +46,3 @@ const ( // ConnectionTypePostgreSQL represents a PostgreSQL database connection ConnectionTypePostgreSQL ConnectionType = "postgresql" ) - -// ConnectionInfo provides information about the database connection -type ConnectionInfo struct { - // Type indicates the type of database connection - Type ConnectionType - // IsConnected indicates whether the database is currently connected - IsConnected bool - // Raw provides access to the underlying connection object, which will vary by implementation - // For PostgreSQL, this will be *pgx.Conn - // For MemoryDB, this will be map[string]*model.MCPRegistry - Raw any -} diff --git a/internal/database/import_test.go b/internal/database/import_test.go deleted file mode 100644 index 17de6f8..0000000 --- a/internal/database/import_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package database_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/modelcontextprotocol/registry/internal/database" - apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" - "github.com/modelcontextprotocol/registry/pkg/model" - "github.com/stretchr/testify/assert" -) - -func TestReadSeedFile_LocalFile(t *testing.T) { - // Create a temporary seed file in extension wrapper format - tempFile := "/tmp/test_seed.json" - seedData := []apiv0.ServerJSON{ - { - Name: "com.example/test-server-1", - Description: "Test server 1", - Repository: model.Repository{ - URL: "https://github.com/test/repo1", - Source: "github", - ID: "123", - }, - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - }, - Remotes: []model.Remote{ - { - URL: "https://example.com/remote", - }, - }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "test-id-1", - PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), - IsLatest: true, - ReleaseDate: "2023-01-01T00:00:00Z", - }, - }, - }, - } - - // Write seed data to temp file - data, err := json.Marshal(seedData) - assert.NoError(t, err) - - err = func() error { - f, err := os.Create(tempFile) - if err != nil { - return err - } - defer f.Close() - _, err = f.Write(data) - return err - }() - assert.NoError(t, err) - defer os.Remove(tempFile) - - // Test reading the file - result, err := database.ReadSeedFile(context.Background(), tempFile) - assert.NoError(t, err) - assert.Len(t, result, 1) - assert.Equal(t, "com.example/test-server-1", result[0].Name) -} - -func TestReadSeedFile_DirectHTTPURL(t *testing.T) { - // Create a test HTTP server that serves seed JSON directly in extension wrapper format - seedData := []apiv0.ServerJSON{ - { - Name: "com.example/test-server-1", - Description: "Test server 1", - Remotes: []model.Remote{ - { - URL: "https://example.com/remote", - }, - }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "test-id-1", - PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), - IsLatest: true, - ReleaseDate: "2023-01-01T00:00:00Z", - }, - }, - }, - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(seedData); err != nil { - http.Error(w, "Failed to encode JSON", http.StatusInternalServerError) - } - })) - defer server.Close() - - // Test reading from HTTP URL ending in .json - result, err := database.ReadSeedFile(context.Background(), server.URL+"/seed.json") - assert.NoError(t, err) - assert.Len(t, result, 1) - assert.Equal(t, "com.example/test-server-1", result[0].Name) -} - -func TestReadSeedFile_RegistryURL(t *testing.T) { - // Create mock registry responses - server1 := apiv0.ServerJSON{ - Name: "Test Server 1", - Description: "First test server", - Packages: []model.Package{ - { - RegistryType: "npm", - RegistryBaseURL: "https://registry.npmjs.org", - Identifier: "test-package-1", - Version: "1.0.0", - }, - }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "server-1", - PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), - IsLatest: true, - ReleaseDate: "2023-01-01T00:00:00Z", - }, - }, - } - server2 := apiv0.ServerJSON{ - Name: "Test Server 2", - Description: "Second test server", - Packages: []model.Package{ - { - RegistryType: "npm", - RegistryBaseURL: "https://registry.npmjs.org", - Identifier: "test-package-2", - Version: "2.0.0", - }, - }, - Meta: &apiv0.ServerMeta{ - IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ - ID: "server-2", - PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), - IsLatest: true, - ReleaseDate: "2023-01-01T00:00:00Z", - }, - }, - } - - serverDetail1 := apiv0.ServerJSON{ - Name: "Test Server 1", - Description: "First test server", - Packages: []model.Package{ - { - RegistryType: "npm", - RegistryBaseURL: "https://registry.npmjs.org", - Identifier: "test-package-1", - Version: "1.0.0", - }, - }, - } - serverDetail2 := apiv0.ServerJSON{ - Name: "Test Server 2", - Description: "Second test server", - Packages: []model.Package{ - { - RegistryType: "npm", - RegistryBaseURL: "https://registry.npmjs.org", - Identifier: "test-package-2", - Version: "2.0.0", - }, - }, - } - - // Create a test HTTP server that simulates the registry API - mux := http.NewServeMux() - - // Handle /v0/servers endpoint (paginated) - mux.HandleFunc("/v0/servers", func(w http.ResponseWriter, r *http.Request) { - cursor := r.URL.Query().Get("cursor") - - type Metadata struct { - NextCursor string `json:"next_cursor,omitempty"` - Count int `json:"count,omitempty"` - } - - type PaginatedResponse struct { - Servers []apiv0.ServerJSON `json:"servers"` - Metadata *Metadata `json:"metadata,omitempty"` - } - - var response PaginatedResponse - switch cursor { - case "": - // First page - response = PaginatedResponse{ - Servers: []apiv0.ServerJSON{server1}, - Metadata: &Metadata{ - NextCursor: "next-cursor-1", - Count: 1, - }, - } - case "next-cursor-1": - // Second page - response = PaginatedResponse{ - Servers: []apiv0.ServerJSON{server2}, - Metadata: &Metadata{ - Count: 1, - // No NextCursor means end of pagination - }, - } - default: - // No more pages - response = PaginatedResponse{ - Servers: []apiv0.ServerJSON{}, - Metadata: &Metadata{}, - } - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - http.Error(w, "Failed to encode JSON", http.StatusInternalServerError) - } - }) - - // Handle individual server detail endpoints - mux.HandleFunc("/v0/servers/server-1", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(serverDetail1); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - }) - - mux.HandleFunc("/v0/servers/server-2", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(serverDetail2); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - }) - - server := httptest.NewServer(mux) - defer server.Close() - - // Test reading from registry root URL (this should trigger pagination) - result, err := database.ReadSeedFile(context.Background(), server.URL+"/v0/servers") - assert.NoError(t, err) - assert.Len(t, result, 2) - - // Verify the servers were imported correctly - assert.Equal(t, "Test Server 1", result[0].Name) - assert.Equal(t, "Test Server 2", result[1].Name) - - // Verify packages were imported with new schema - assert.Len(t, result[0].Packages, 1) - assert.Equal(t, "npm", result[0].Packages[0].RegistryType) - assert.Equal(t, "https://registry.npmjs.org", result[0].Packages[0].RegistryBaseURL) - assert.Equal(t, "test-package-1", result[0].Packages[0].Identifier) - assert.Len(t, result[1].Packages, 1) - assert.Equal(t, "npm", result[1].Packages[0].RegistryType) - assert.Equal(t, "https://registry.npmjs.org", result[1].Packages[0].RegistryBaseURL) - assert.Equal(t, "test-package-2", result[1].Packages[0].Identifier) - - // Verify metadata was extracted - assert.Equal(t, "server-1", result[0].Meta.IOModelContextProtocolRegistry.ID) - assert.Equal(t, "server-2", result[1].Meta.IOModelContextProtocolRegistry.ID) -} diff --git a/internal/database/memory.go b/internal/database/memory.go index 546ec2a..3ffd868 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -5,7 +5,6 @@ import ( "fmt" "sort" "sync" - "time" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" ) @@ -16,38 +15,17 @@ type MemoryDB struct { mu sync.RWMutex } -// NewMemoryDB creates a new instance of the in-memory database -func NewMemoryDB(e map[string]*apiv0.ServerJSON) *MemoryDB { +func NewMemoryDB() *MemoryDB { // Convert input ServerJSON entries to have proper metadata serverRecords := make(map[string]*apiv0.ServerJSON) - for registryID, serverDetail := range e { - // Create a copy and add registry metadata - now := time.Now() - server := *serverDetail // Copy the server - if server.Meta == nil { - server.Meta = &apiv0.ServerMeta{} - } - server.Meta.IOModelContextProtocolRegistry = &apiv0.RegistryExtensions{ - ID: registryID, - PublishedAt: now, - UpdatedAt: now, - IsLatest: true, - ReleaseDate: now.Format(time.RFC3339), - } - if server.Meta.Publisher == nil { - server.Meta.Publisher = make(map[string]interface{}) - } - serverRecords[registryID] = &server - } return &MemoryDB{ entries: serverRecords, } } -//nolint:cyclop,gocyclo,gocognit // Complexity from filtering logic is acceptable for memory implementation func (db *MemoryDB) List( ctx context.Context, - filter map[string]any, + filter *ServerFilter, cursor string, limit int, ) ([]*apiv0.ServerJSON, string, error) { @@ -62,73 +40,20 @@ func (db *MemoryDB) List( db.mu.RLock() defer db.mu.RUnlock() - // Convert all entries to a slice for pagination, filter by is_latest + // Convert all entries to a slice for pagination var allEntries []*apiv0.ServerJSON for _, entry := range db.entries { - if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil && entry.Meta.IOModelContextProtocolRegistry.IsLatest { - allEntries = append(allEntries, entry) - } + allEntries = append(allEntries, entry) } - // Simple filtering implementation - var filteredEntries []*apiv0.ServerJSON - for _, entry := range allEntries { - include := true + // Apply filtering and sorting + filteredEntries := db.filterAndSort(allEntries, filter) - // Apply filters if any - for key, value := range filter { - switch key { - case "name": - if entry.Name != value.(string) { - include = false - } - case "version": - if entry.VersionDetail.Version != value.(string) { - include = false - } - case "status": - if string(entry.Status) != value.(string) { - include = false - } - case "remote_url": - found := false - remoteURL := value.(string) - for _, remote := range entry.Remotes { - if remote.URL == remoteURL { - found = true - break - } - } - if !found { - include = false - } - } - } - - if include { - filteredEntries = append(filteredEntries, entry) - } - } - - // Sort filteredEntries by registry metadata ID for consistent pagination - sort.Slice(filteredEntries, func(i, j int) bool { - iID := "" - jID := "" - if filteredEntries[i].Meta != nil && filteredEntries[i].Meta.IOModelContextProtocolRegistry != nil { - iID = filteredEntries[i].Meta.IOModelContextProtocolRegistry.ID - } - if filteredEntries[j].Meta != nil && filteredEntries[j].Meta.IOModelContextProtocolRegistry != nil { - jID = filteredEntries[j].Meta.IOModelContextProtocolRegistry.ID - } - return iID < jID - }) - - // Find starting point for cursor-based pagination using registry metadata ID + // Find starting point for cursor-based pagination startIdx := 0 if cursor != "" { for i, entry := range filteredEntries { - if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil && - entry.Meta.IOModelContextProtocolRegistry.ID == cursor { + if db.getRegistryID(entry) == cursor { startIdx = i + 1 // Start after the cursor break } @@ -145,19 +70,15 @@ func (db *MemoryDB) List( result = []*apiv0.ServerJSON{} } - // Determine next cursor using registry metadata ID + // Determine next cursor nextCursor := "" if endIdx < len(filteredEntries) && len(result) > 0 { - lastEntry := result[len(result)-1] - if lastEntry.Meta != nil && lastEntry.Meta.IOModelContextProtocolRegistry != nil { - nextCursor = lastEntry.Meta.IOModelContextProtocolRegistry.ID - } + nextCursor = db.getRegistryID(result[len(result)-1]) } return result, nextCursor, nil } -// GetByID retrieves a single ServerRecord by its registry metadata ID func (db *MemoryDB) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) { if ctx.Err() != nil { return nil, ctx.Err() @@ -176,103 +97,28 @@ func (db *MemoryDB) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, return nil, ErrNotFound } -// Publish adds a new server to the database with separated server.json and extensions -func (db *MemoryDB) Publish(ctx context.Context, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv0.RegistryExtensions) (*apiv0.ServerJSON, error) { +func (db *MemoryDB) CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) { if ctx.Err() != nil { return nil, ctx.Err() } - // Extract name and version for validation - name := serverDetail.Name - if name == "" { - return nil, fmt.Errorf("name is required in server JSON") + // Get the ID from the registry metadata + if server.Meta == nil || server.Meta.IOModelContextProtocolRegistry == nil { + return nil, fmt.Errorf("server must have registry metadata with ID") } - version := serverDetail.VersionDetail.Version - if version == "" { - return nil, fmt.Errorf("version is required in version_detail") - } + id := server.Meta.IOModelContextProtocolRegistry.ID db.mu.Lock() defer db.mu.Unlock() - // Validate repository URL - if serverDetail.Repository.URL == "" { - return nil, ErrInvalidInput - } - - // Create server record with metadata - record := serverDetail // Copy the input - - // Initialize meta if not present - if record.Meta == nil { - record.Meta = &apiv0.ServerMeta{} - } - - // Set registry metadata - record.Meta.IOModelContextProtocolRegistry = ®istryMetadata - - // Set publisher extensions if provided - if len(publisherExtensions) > 0 { - record.Meta.Publisher = publisherExtensions - } - // Store the record using registry metadata ID - db.entries[registryMetadata.ID] = &record + db.entries[id] = server - return &record, nil + return server, 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() - } - - // This will need to be updated to work with the new ServerRecord format - // Read seed data using the shared ReadSeedFile function - seedData, err := ReadSeedFile(ctx, seedFilePath) - if err != nil { - return fmt.Errorf("failed to read seed file: %w", err) - } - - // Lock for concurrent access - db.mu.Lock() - defer db.mu.Unlock() - - // Import each server - for _, record := range seedData { - // Use the registry metadata ID as the map key - if record.Meta != nil && record.Meta.IOModelContextProtocolRegistry != nil { - db.entries[record.Meta.IOModelContextProtocolRegistry.ID] = record - } - } - - 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 { - if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil { - entry.Meta.IOModelContextProtocolRegistry.IsLatest = isLatest - entry.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now() - } - return nil - } - - return ErrNotFound -} - -// UpdateServer updates an existing server record with new server details -func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}) (*apiv0.ServerJSON, error) { +func (db *MemoryDB) UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) { if ctx.Err() != nil { return nil, ctx.Err() } @@ -280,40 +126,75 @@ func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail ap db.mu.Lock() defer db.mu.Unlock() - entry, exists := db.entries[id] + _, exists := db.entries[id] if !exists { return nil, ErrNotFound } - // Update the server details by copying fields from serverDetail - *entry = serverDetail - - // Ensure meta exists and update it - if entry.Meta == nil { - entry.Meta = &apiv0.ServerMeta{} - } - if entry.Meta.IOModelContextProtocolRegistry != nil { - entry.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now() - } - if len(publisherExtensions) > 0 { - entry.Meta.Publisher = publisherExtensions - } + // Update the server + db.entries[id] = server // Return the updated record - return entry, nil + return server, 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, +// filterAndSort applies filtering and sorting to the entries +func (db *MemoryDB) filterAndSort(allEntries []*apiv0.ServerJSON, filter *ServerFilter) []*apiv0.ServerJSON { + // Apply filtering + var filteredEntries []*apiv0.ServerJSON + for _, entry := range allEntries { + if db.matchesFilter(entry, filter) { + filteredEntries = append(filteredEntries, entry) + } } + + // Sort by registry metadata ID for consistent pagination + sort.Slice(filteredEntries, func(i, j int) bool { + iID := db.getRegistryID(filteredEntries[i]) + jID := db.getRegistryID(filteredEntries[j]) + return iID < jID + }) + + return filteredEntries +} + +// matchesFilter checks if an entry matches the provided filter +func (db *MemoryDB) matchesFilter(entry *apiv0.ServerJSON, filter *ServerFilter) bool { + if filter == nil { + return true + } + + // Check name filter + if filter.Name != nil && entry.Name != *filter.Name { + return false + } + + // Check remote URL filter + if filter.RemoteURL != nil { + found := false + for _, remote := range entry.Remotes { + if remote.URL == *filter.RemoteURL { + found = true + break + } + } + if !found { + return false + } + } + + return true +} + +// getRegistryID safely extracts the registry ID from an entry +func (db *MemoryDB) getRegistryID(entry *apiv0.ServerJSON) string { + if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil { + return entry.Meta.IOModelContextProtocolRegistry.ID + } + return "" } diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 9bf91da..173cb83 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -5,9 +5,7 @@ import ( "encoding/json" "errors" "fmt" - "log" "strings" - "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -42,10 +40,9 @@ func NewPostgreSQL(ctx context.Context, connectionURI string) (*PostgreSQL, erro }, nil } -// List retrieves ServerJSON entries with optional filtering and pagination func (db *PostgreSQL) List( ctx context.Context, - filter map[string]any, + filter *ServerFilter, cursor string, limit int, ) ([]*apiv0.ServerJSON, string, error) { @@ -57,30 +54,21 @@ func (db *PostgreSQL) List( return nil, "", ctx.Err() } - // Build WHERE clause for filtering - always filter by is_latest = true by default + // Build WHERE clause for filtering var whereConditions []string - whereConditions = append(whereConditions, "(value->'_meta'->'io.modelcontextprotocol.registry'->>'is_latest')::boolean = true") args := []any{} argIndex := 1 // Add filters using JSON operators - for k, v := range filter { - switch k { - case "name": + if filter != nil { + if filter.Name != nil { whereConditions = append(whereConditions, fmt.Sprintf("value->>'name' = $%d", argIndex)) - args = append(args, v) + args = append(args, *filter.Name) argIndex++ - case "version": - whereConditions = append(whereConditions, fmt.Sprintf("value->'version_detail'->>'version' = $%d", argIndex)) - args = append(args, v) - argIndex++ - case "status": - whereConditions = append(whereConditions, fmt.Sprintf("value->>'status' = $%d", argIndex)) - args = append(args, v) - argIndex++ - case "remote_url": + } + if filter.RemoteURL != nil { whereConditions = append(whereConditions, fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(value->'remotes') AS remote WHERE remote->>'url' = $%d)", argIndex)) - args = append(args, v) + args = append(args, *filter.RemoteURL) argIndex++ } } @@ -151,7 +139,6 @@ func (db *PostgreSQL) List( return results, nextCursor, nil } -// GetByID retrieves a single ServerJSON by its registry metadata ID func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) { if ctx.Err() != nil { return nil, ctx.Err() @@ -183,27 +170,18 @@ func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON return &serverJSON, nil } -// Publish adds a new server to the simple servers table -func (db *PostgreSQL) Publish(ctx context.Context, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv0.RegistryExtensions) (*apiv0.ServerJSON, error) { +// CreateServer adds a new server to the database +func (db *PostgreSQL) CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) { if ctx.Err() != nil { return nil, ctx.Err() } - // Create complete server with metadata - server := serverDetail // Copy the input - - // Initialize meta if not present - if server.Meta == nil { - server.Meta = &apiv0.ServerMeta{} + // Get the ID from the registry metadata + if server.Meta == nil || server.Meta.IOModelContextProtocolRegistry == nil { + return nil, fmt.Errorf("server must have registry metadata with ID") } - // Set registry metadata - server.Meta.IOModelContextProtocolRegistry = ®istryMetadata - - // Set publisher extensions if provided - if len(publisherExtensions) > 0 { - server.Meta.Publisher = publisherExtensions - } + id := server.Meta.IOModelContextProtocolRegistry.ID // Marshal the complete server to JSONB valueJSON, err := json.Marshal(server) @@ -217,130 +195,22 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail apiv0.ServerJSON VALUES ($1, $2) ` - _, err = db.conn.Exec(ctx, query, registryMetadata.ID, valueJSON) + _, err = db.conn.Exec(ctx, query, id, valueJSON) if err != nil { return nil, fmt.Errorf("failed to insert server: %w", err) } - return &server, nil -} - -// ImportSeed imports initial data from a seed file into PostgreSQL -func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error { - // Read seed data using the shared ReadSeedFile function - seedData, err := ReadSeedFile(ctx, seedFilePath) - if err != nil { - return fmt.Errorf("failed to read seed file: %w", err) - } - - // Start a transaction for batch import - tx, err := db.conn.Begin(ctx) - if err != nil { - return fmt.Errorf("failed to start transaction: %w", err) - } - defer func() { - if rollbackErr := tx.Rollback(ctx); rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) { - log.Printf("Failed to rollback transaction: %v", rollbackErr) - } - }() - - // Import each server - for _, record := range seedData { - // Marshal the server to JSONB - valueJSON, err := json.Marshal(record) - if err != nil { - return fmt.Errorf("failed to marshal server JSON for import: %w", err) - } - - // Get registry ID from metadata - var registryID string - if record.Meta != nil && record.Meta.IOModelContextProtocolRegistry != nil { - registryID = record.Meta.IOModelContextProtocolRegistry.ID - } else { - registryID = uuid.New().String() // Generate ID if not present - } - - // Insert into simple servers table - _, err = tx.Exec(ctx, "INSERT INTO servers (id, value) VALUES ($1, $2)", registryID, valueJSON) - if err != nil { - return fmt.Errorf("failed to import server %s: %w", record.Name, err) - } - } - - // Commit the transaction - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("failed to commit seed import transaction: %w", err) - } - - 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 servers - SET value = jsonb_set( - jsonb_set( - value, - '{_meta,io.modelcontextprotocol.registry,is_latest}', - $1::jsonb - ), - '{_meta,io.modelcontextprotocol.registry,updated_at}', - $2::jsonb - ) - WHERE (value->'_meta'->'io.modelcontextprotocol.registry'->>'id') = $3 - ` - - result, err := db.conn.Exec(ctx, query, - fmt.Sprintf("%t", isLatest), - fmt.Sprintf("\"%s\"", time.Now().Format(time.RFC3339)), - id) - if err != nil { - return fmt.Errorf("failed to update latest flag: %w", err) - } - - if result.RowsAffected() == 0 { - return ErrNotFound - } - - return nil + return server, nil } // UpdateServer updates an existing server record with new server details -func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}) (*apiv0.ServerJSON, error) { +func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) { if ctx.Err() != nil { return nil, ctx.Err() } - // Get the existing server to preserve registry metadata - existingServer, err := db.GetByID(ctx, id) - if err != nil { - return nil, err - } - - // Update the server details while preserving registry metadata - updatedServer := serverDetail - if updatedServer.Meta == nil { - updatedServer.Meta = &apiv0.ServerMeta{} - } - - // Preserve existing registry metadata but update timestamp - if existingServer.Meta != nil && existingServer.Meta.IOModelContextProtocolRegistry != nil { - updatedServer.Meta.IOModelContextProtocolRegistry = existingServer.Meta.IOModelContextProtocolRegistry - updatedServer.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now() - } - - // Update publisher extensions if provided - if len(publisherExtensions) > 0 { - updatedServer.Meta.Publisher = publisherExtensions - } - // Marshal updated server - valueJSON, err := json.Marshal(updatedServer) + valueJSON, err := json.Marshal(server) if err != nil { return nil, fmt.Errorf("failed to marshal updated server: %w", err) } @@ -361,27 +231,10 @@ func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail return nil, ErrNotFound } - return &updatedServer, nil + return server, nil } // Close closes the database connection func (db *PostgreSQL) Close() error { return db.conn.Close(context.Background()) } - -// Connection returns information about the database connection -func (db *PostgreSQL) Connection() *ConnectionInfo { - isConnected := false - if db.conn != nil { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - err := db.conn.Ping(ctx) - isConnected = (err == nil) - } - - return &ConnectionInfo{ - Type: ConnectionTypePostgreSQL, - IsConnected: isConnected, - Raw: db.conn, - } -} diff --git a/internal/database/import.go b/internal/importer/importer.go similarity index 83% rename from internal/database/import.go rename to internal/importer/importer.go index 835c20d..30895c3 100644 --- a/internal/database/import.go +++ b/internal/importer/importer.go @@ -1,4 +1,4 @@ -package database +package importer import ( "context" @@ -10,16 +10,45 @@ import ( "os" "strings" + "github.com/modelcontextprotocol/registry/internal/database" "github.com/modelcontextprotocol/registry/internal/validators" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" ) -// ReadSeedFile reads seed data from various sources: +// Service handles importing seed data into the registry +type Service struct { + db database.Database +} + +// NewService creates a new importer service +func NewService(db database.Database) *Service { + return &Service{db: db} +} + +// ImportFromPath imports seed data from various sources: // 1. Local file paths (*.json files) - expects extension wrapper format // 2. Direct HTTP URLs to seed.json files - expects extension wrapper format // 3. Registry root URLs (automatically appends /v0/servers and paginates) -// Only the extension wrapper format is supported (array of ServerResponse objects) -func ReadSeedFile(ctx context.Context, path string) ([]*apiv0.ServerJSON, error) { +func (s *Service) ImportFromPath(ctx context.Context, path string) error { + servers, err := readSeedFile(ctx, path) + if err != nil { + return fmt.Errorf("failed to read seed data: %w", err) + } + + // Import each server using CreateServer + for _, server := range servers { + _, err := s.db.CreateServer(ctx, server) + if err != nil { + return fmt.Errorf("failed to import server %s: %w", server.Name, err) + } + } + + return nil +} + + +// readSeedFile reads seed data from various sources +func readSeedFile(ctx context.Context, path string) ([]*apiv0.ServerJSON, error) { var data []byte var err error @@ -152,4 +181,4 @@ func convertServerResponseToRecord(response apiv0.ServerJSON) *apiv0.ServerJSON // The response is already in the correct flattened format // Just return a pointer to it return &response -} +} \ No newline at end of file diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go new file mode 100644 index 0000000..8a27b35 --- /dev/null +++ b/internal/importer/importer_test.go @@ -0,0 +1,201 @@ +package importer_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/modelcontextprotocol/registry/internal/database" + "github.com/modelcontextprotocol/registry/internal/importer" + apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImportService_LocalFile(t *testing.T) { + // Create a temporary seed file + tempFile := "/tmp/test_import_seed.json" + seedData := []apiv0.ServerJSON{ + { + Name: "io.github.test/test-server-1", + Description: "Test server 1", + Repository: model.Repository{ + URL: "https://github.com/test/repo1", + Source: "github", + ID: "123", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + }, + Meta: &apiv0.ServerMeta{ + IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ + ID: "test-id-1", + PublishedAt: time.Now(), + UpdatedAt: time.Now(), + IsLatest: true, + }, + }, + }, + } + + jsonData, err := json.Marshal(seedData) + require.NoError(t, err) + + err = os.WriteFile(tempFile, jsonData, 0600) + require.NoError(t, err) + defer os.Remove(tempFile) + + memDB := database.NewMemoryDB() + + // Create importer service and test import + service := importer.NewService(memDB) + err = service.ImportFromPath(context.Background(), tempFile) + require.NoError(t, err) + + // Verify the server was imported + servers, _, err := memDB.List(context.Background(), nil, "", 10) + require.NoError(t, err) + assert.Len(t, servers, 1) + assert.Equal(t, "io.github.test/test-server-1", servers[0].Name) +} + +func TestImportService_HTTPFile(t *testing.T) { + // Create a test HTTP server + seedData := []apiv0.ServerJSON{ + { + Name: "io.github.test/http-test-server", + Description: "HTTP test server", + Repository: model.Repository{ + URL: "https://github.com/test/repo1", + Source: "github", + ID: "123", + }, + VersionDetail: model.VersionDetail{ + Version: "2.0.0", + }, + Meta: &apiv0.ServerMeta{ + IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ + ID: "test-id-2", + PublishedAt: time.Now(), + UpdatedAt: time.Now(), + IsLatest: true, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(seedData) + })) + defer server.Close() + + memDB := database.NewMemoryDB() + + // Create importer service and test import + service := importer.NewService(memDB) + err := service.ImportFromPath(context.Background(), server.URL+"/seed.json") + require.NoError(t, err) + + // Verify the server was imported + servers, _, err := memDB.List(context.Background(), nil, "", 10) + require.NoError(t, err) + assert.Len(t, servers, 1) + assert.Equal(t, "io.github.test/http-test-server", servers[0].Name) +} + +func TestImportService_RegistryAPI(t *testing.T) { + // Create test data + testServers := []apiv0.ServerJSON{ + { + Name: "io.github.test/api-server-1", + Description: "API server 1", + Repository: model.Repository{ + URL: "https://github.com/test/repo1", + Source: "github", + ID: "123", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + }, + Meta: &apiv0.ServerMeta{ + IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ + ID: "api-test-id-1", + PublishedAt: time.Now(), + UpdatedAt: time.Now(), + IsLatest: true, + }, + }, + }, + { + Name: "io.github.test/api-server-2", + Description: "API server 2", + Repository: model.Repository{ + URL: "https://github.com/test/repo2", + Source: "github", + ID: "456", + }, + VersionDetail: model.VersionDetail{ + Version: "2.0.0", + }, + Meta: &apiv0.ServerMeta{ + IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{ + ID: "api-test-id-2", + PublishedAt: time.Now(), + UpdatedAt: time.Now(), + IsLatest: true, + }, + }, + }, + } + + // Create test server that responds with paginated results + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cursor := r.URL.Query().Get("cursor") + + var response struct { + Servers []apiv0.ServerJSON `json:"servers"` + Metadata *struct { + NextCursor string `json:"next_cursor,omitempty"` + } `json:"metadata,omitempty"` + } + + switch cursor { + case "": + // First page + response.Servers = []apiv0.ServerJSON{testServers[0]} + response.Metadata = &struct { + NextCursor string `json:"next_cursor,omitempty"` + }{NextCursor: "page2"} + case "page2": + // Second page + response.Servers = []apiv0.ServerJSON{testServers[1]} + // No next cursor means end of results + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + memDB := database.NewMemoryDB() + + // Create importer service and test import + service := importer.NewService(memDB) + err := service.ImportFromPath(context.Background(), server.URL+"/v0/servers") + require.NoError(t, err) + + // Verify both servers were imported + servers, _, err := memDB.List(context.Background(), nil, "", 10) + require.NoError(t, err) + assert.Len(t, servers, 2) + + names := []string{servers[0].Name, servers[1].Name} + assert.Contains(t, names, "io.github.test/api-server-1") + assert.Contains(t, names, "io.github.test/api-server-2") +} diff --git a/internal/service/fake_service.go b/internal/service/fake_service.go deleted file mode 100644 index ec6b4d1..0000000 --- a/internal/service/fake_service.go +++ /dev/null @@ -1,176 +0,0 @@ -package service - -import ( - "context" - "time" - - "github.com/google/uuid" - "github.com/modelcontextprotocol/registry/internal/database" - "github.com/modelcontextprotocol/registry/internal/validators" - apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" - "github.com/modelcontextprotocol/registry/pkg/model" -) - -// fakeRegistryService implements RegistryService interface with an in-memory database -type fakeRegistryService struct { - db *database.MemoryDB -} - -// NewFakeRegistryService creates a new fake registry service with pre-populated data -func NewFakeRegistryService() RegistryService { - // Sample registry entries with updated model structure using ServerJSON - serverDetails := []*apiv0.ServerJSON{ - { - Name: "bluegreen/mcp-server", - Description: "A dummy MCP registry for testing", - Repository: model.Repository{ - URL: "https://github.com/example/mcp-1", - Source: "github", - ID: "example/mcp-1", - }, - VersionDetail: model.VersionDetail{ - Version: "1.0.0", - }, - }, - { - Name: "orangepurple/mcp-server", - Description: "Another dummy MCP registry for testing", - Repository: model.Repository{ - URL: "https://github.com/example/mcp-2", - Source: "github", - ID: "example/mcp-2", - }, - VersionDetail: model.VersionDetail{ - Version: "0.9.0", - }, - }, - { - Name: "greenyellow/mcp-server", - Description: "Yet another dummy MCP registry for testing", - Repository: model.Repository{ - URL: "https://github.com/example/mcp-3", - Source: "github", - ID: "example/mcp-3", - }, - VersionDetail: model.VersionDetail{ - Version: "0.9.5", - }, - }, - } - - // Create a new in-memory database using registry metadata IDs - serverDetailMap := make(map[string]*apiv0.ServerJSON) - for _, entry := range serverDetails { - registryID := uuid.New().String() // Generate registry metadata ID - serverDetailMap[registryID] = entry - } - memDB := database.NewMemoryDB(serverDetailMap) - return &fakeRegistryService{ - db: memDB, - } -} - -// List retrieves servers with extension wrapper format -func (s *fakeRegistryService) List(cursor string, limit int) ([]apiv0.ServerJSON, string, error) { - // Create a timeout context for the database operation - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Use the database's List method to get ServerRecord entries - serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit) - if err != nil { - return nil, "", err - } - - // Return ServerRecords directly (they're now the same as ServerResponse) - result := make([]apiv0.ServerJSON, len(serverRecords)) - for i, record := range serverRecords { - result[i] = *record - } - - return result, nextCursor, nil -} - -// GetByID retrieves a specific server by its registry metadata ID in extension wrapper format -func (s *fakeRegistryService) GetByID(id string) (*apiv0.ServerJSON, 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 - } - - // Return ServerRecord directly (it's now the same as ServerResponse) - return serverRecord, nil -} - -// Publish publishes a server with separated extensions -func (s *fakeRegistryService) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON, 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 := validators.ValidatePublishRequest(req); err != nil { - return nil, err - } - - // Extract publisher extensions from _meta.publisher - publisherExtensions := make(map[string]interface{}) - if req.Meta != nil && req.Meta.Publisher != nil { - publisherExtensions = req.Meta.Publisher - } - - // Create registry metadata for fake service (always marks as latest) - now := time.Now() - registryMetadata := apiv0.RegistryExtensions{ - 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, publisherExtensions, registryMetadata) - if err != nil { - return nil, err - } - - // Return ServerRecord directly (it's now the same as ServerResponse) - return serverRecord, nil -} - -// EditServer updates an existing server with new details (admin operation) -func (s *fakeRegistryService) EditServer(id string, req apiv0.ServerJSON) (*apiv0.ServerJSON, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Validate the request - if err := validators.ValidatePublishRequest(req); err != nil { - return nil, err - } - - // Extract publisher extensions from _meta.publisher - publisherExtensions := make(map[string]interface{}) - if req.Meta != nil && req.Meta.Publisher != nil { - publisherExtensions = req.Meta.Publisher - } - - // Update server in database - serverRecord, err := s.db.UpdateServer(ctx, id, req, publisherExtensions) - if err != nil { - return nil, err - } - - // Return ServerRecord directly (it's now the same as ServerResponse) - return serverRecord, nil -} - -// 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 1cc8076..8e640c3 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -37,7 +37,7 @@ func (s *registryServiceImpl) List(cursor string, limit int) ([]apiv0.ServerJSON limit = 30 } - // Use the database's List method with pagination + // Use the database's ListServers method with pagination serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit) if err != nil { return nil, "", err @@ -58,7 +58,6 @@ func (s *registryServiceImpl) GetByID(id string) (*apiv0.ServerJSON, error) { 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 @@ -87,7 +86,8 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON, return nil, err } - existingServerVersions, _, err := s.db.List(ctx, map[string]any{"name": serverJSON.Name}, "", maxServerVersionsPerServer) + filter := &database.ServerFilter{Name: &serverJSON.Name} + existingServerVersions, _, err := s.db.List(ctx, filter, "", maxServerVersionsPerServer) if err != nil && !errors.Is(err, database.ErrNotFound) { return nil, err } @@ -121,8 +121,16 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON, ) > 0 } - // Create registry metadata with service-determined values - registryMetadata := apiv0.RegistryExtensions{ + // Create complete server with metadata + server := serverJSON // Copy the input + + // Initialize meta if not present + if server.Meta == nil { + server.Meta = &apiv0.ServerMeta{} + } + + // Set registry metadata + server.Meta.IOModelContextProtocolRegistry = &apiv0.RegistryExtensions{ ID: uuid.New().String(), PublishedAt: publishTime, UpdatedAt: publishTime, @@ -130,14 +138,8 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON, ReleaseDate: publishTime.Format(time.RFC3339), } - // Extract publisher extensions from _meta.publisher - publisherExtensions := make(map[string]interface{}) - if serverJSON.Meta != nil && serverJSON.Meta.Publisher != nil { - publisherExtensions = serverJSON.Meta.Publisher - } - - // Publish to database with the registry metadata (still using old format internally) - serverRecord, err := s.db.Publish(ctx, serverJSON, publisherExtensions, registryMetadata) + // Create server in database + serverRecord, err := s.db.CreateServer(ctx, &server) if err != nil { return nil, err } @@ -149,7 +151,10 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON, existingLatestID = existingLatest.Meta.IOModelContextProtocolRegistry.ID } if existingLatestID != "" { - if err := s.db.UpdateLatestFlag(ctx, existingLatestID, false); err != nil { + // Update the existing server to set is_latest = false + existingLatest.Meta.IOModelContextProtocolRegistry.IsLatest = false + existingLatest.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now() + if _, err := s.db.UpdateServer(ctx, existingLatestID, existingLatest); err != nil { return nil, err } } @@ -164,9 +169,7 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context, // Check each remote URL in the new server for conflicts for _, remote := range serverDetail.Remotes { // Use filter to find servers with this remote URL - filter := map[string]any{ - "remote_url": remote.URL, - } + filter := &database.ServerFilter{RemoteURL: &remote.URL} conflictingServers, _, err := s.db.List(ctx, filter, "", 1000) if err != nil { @@ -187,8 +190,8 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context, // getCurrentLatestVersion finds the current latest version from existing server versions func (s *registryServiceImpl) getCurrentLatestVersion(existingServerVersions []*apiv0.ServerJSON) *apiv0.ServerJSON { for _, server := range existingServerVersions { - if server.Meta != nil && server.Meta.IOModelContextProtocolRegistry != nil && - server.Meta.IOModelContextProtocolRegistry.IsLatest { + if server.Meta != nil && server.Meta.IOModelContextProtocolRegistry != nil && + server.Meta.IOModelContextProtocolRegistry.IsLatest { return server } } @@ -212,14 +215,8 @@ func (s *registryServiceImpl) EditServer(id string, req apiv0.ServerJSON) (*apiv return nil, err } - // Extract publisher extensions from _meta.publisher - publisherExtensions := make(map[string]interface{}) - if serverJSON.Meta != nil && serverJSON.Meta.Publisher != nil { - publisherExtensions = serverJSON.Meta.Publisher - } - // Update server in database - serverRecord, err := s.db.UpdateServer(ctx, id, serverJSON, publisherExtensions) + serverRecord, err := s.db.UpdateServer(ctx, id, &serverJSON) if err != nil { return nil, err } @@ -227,4 +224,3 @@ func (s *registryServiceImpl) EditServer(id string, req apiv0.ServerJSON) (*apiv // Return the server record directly return serverRecord, nil } - diff --git a/internal/service/registry_service_test.go b/internal/service/registry_service_test.go index 06e6a62..d0433d6 100644 --- a/internal/service/registry_service_test.go +++ b/internal/service/registry_service_test.go @@ -37,9 +37,16 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) { }, } - memDB := database.NewMemoryDB(existingServers) + memDB := database.NewMemoryDB() service := NewRegistryServiceWithDB(memDB) + for _, server := range existingServers { + _, err := service.Publish(*server) + if err != nil { + t.Fatalf("failed to publish server: %v", err) + } + } + tests := []struct { name string serverDetail apiv0.ServerJSON diff --git a/pkg/model/types.go b/pkg/model/types.go index 0eb9dbc..18400a1 100644 --- a/pkg/model/types.go +++ b/pkg/model/types.go @@ -9,7 +9,6 @@ const ( StatusDeleted Status = "deleted" ) - // Package represents a package configuration type Package struct { // RegistryType indicates how to download packages (e.g., "npm", "pypi", "oci", "mcpb") @@ -92,5 +91,5 @@ type Argument struct { // VersionDetail represents the version details of a server (pure MCP spec, no registry metadata) type VersionDetail struct { - Version string `json:"version"` + Version string `json:"version" minLength:"1"` } diff --git a/tests/integration/main.go b/tests/integration/main.go index 594df85..b7d785f 100644 --- a/tests/integration/main.go +++ b/tests/integration/main.go @@ -217,7 +217,7 @@ func findServerIDByName(serverName string) (string, error) { // Find the server with matching name for _, server := range serverList.Servers { - if server.Name == serverName { + if server.Name == serverName && server.Meta.IOModelContextProtocolRegistry.IsLatest { return server.Meta.IOModelContextProtocolRegistry.ID, nil } }